servcraft 0.1.0 → 0.1.3

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 (217) hide show
  1. package/.claude/settings.local.json +30 -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/LICENSE +21 -0
  9. package/README.md +1102 -1
  10. package/dist/cli/index.cjs +2026 -2168
  11. package/dist/cli/index.cjs.map +1 -1
  12. package/dist/cli/index.js +2026 -2168
  13. package/dist/cli/index.js.map +1 -1
  14. package/dist/index.cjs +595 -616
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +114 -52
  17. package/dist/index.d.ts +114 -52
  18. package/dist/index.js +595 -616
  19. package/dist/index.js.map +1 -1
  20. package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
  21. package/docs/DATABASE_MULTI_ORM.md +399 -0
  22. package/docs/PHASE1_BREAKDOWN.md +346 -0
  23. package/docs/PROGRESS.md +550 -0
  24. package/docs/modules/ANALYTICS.md +226 -0
  25. package/docs/modules/API-VERSIONING.md +252 -0
  26. package/docs/modules/AUDIT.md +192 -0
  27. package/docs/modules/AUTH.md +431 -0
  28. package/docs/modules/CACHE.md +346 -0
  29. package/docs/modules/EMAIL.md +254 -0
  30. package/docs/modules/FEATURE-FLAG.md +291 -0
  31. package/docs/modules/I18N.md +294 -0
  32. package/docs/modules/MEDIA-PROCESSING.md +281 -0
  33. package/docs/modules/MFA.md +266 -0
  34. package/docs/modules/NOTIFICATION.md +311 -0
  35. package/docs/modules/OAUTH.md +237 -0
  36. package/docs/modules/PAYMENT.md +804 -0
  37. package/docs/modules/QUEUE.md +540 -0
  38. package/docs/modules/RATE-LIMIT.md +339 -0
  39. package/docs/modules/SEARCH.md +288 -0
  40. package/docs/modules/SECURITY.md +327 -0
  41. package/docs/modules/SESSION.md +382 -0
  42. package/docs/modules/SWAGGER.md +305 -0
  43. package/docs/modules/UPLOAD.md +296 -0
  44. package/docs/modules/USER.md +505 -0
  45. package/docs/modules/VALIDATION.md +294 -0
  46. package/docs/modules/WEBHOOK.md +270 -0
  47. package/docs/modules/WEBSOCKET.md +691 -0
  48. package/package.json +53 -38
  49. package/prisma/schema.prisma +395 -1
  50. package/src/cli/commands/add-module.ts +520 -87
  51. package/src/cli/commands/db.ts +3 -4
  52. package/src/cli/commands/docs.ts +256 -6
  53. package/src/cli/commands/generate.ts +12 -19
  54. package/src/cli/commands/init.ts +384 -214
  55. package/src/cli/index.ts +0 -4
  56. package/src/cli/templates/repository.ts +6 -1
  57. package/src/cli/templates/routes.ts +6 -21
  58. package/src/cli/utils/docs-generator.ts +6 -7
  59. package/src/cli/utils/env-manager.ts +717 -0
  60. package/src/cli/utils/field-parser.ts +16 -7
  61. package/src/cli/utils/interactive-prompt.ts +223 -0
  62. package/src/cli/utils/template-manager.ts +346 -0
  63. package/src/config/database.config.ts +183 -0
  64. package/src/config/env.ts +0 -10
  65. package/src/config/index.ts +0 -14
  66. package/src/core/server.ts +1 -1
  67. package/src/database/adapters/mongoose.adapter.ts +132 -0
  68. package/src/database/adapters/prisma.adapter.ts +118 -0
  69. package/src/database/connection.ts +190 -0
  70. package/src/database/interfaces/database.interface.ts +85 -0
  71. package/src/database/interfaces/index.ts +7 -0
  72. package/src/database/interfaces/repository.interface.ts +129 -0
  73. package/src/database/models/mongoose/index.ts +7 -0
  74. package/src/database/models/mongoose/payment.schema.ts +347 -0
  75. package/src/database/models/mongoose/user.schema.ts +154 -0
  76. package/src/database/prisma.ts +1 -4
  77. package/src/database/redis.ts +101 -0
  78. package/src/database/repositories/mongoose/index.ts +7 -0
  79. package/src/database/repositories/mongoose/payment.repository.ts +380 -0
  80. package/src/database/repositories/mongoose/user.repository.ts +255 -0
  81. package/src/database/seed.ts +6 -1
  82. package/src/index.ts +9 -20
  83. package/src/middleware/security.ts +2 -6
  84. package/src/modules/analytics/analytics.routes.ts +80 -0
  85. package/src/modules/analytics/analytics.service.ts +364 -0
  86. package/src/modules/analytics/index.ts +18 -0
  87. package/src/modules/analytics/types.ts +180 -0
  88. package/src/modules/api-versioning/index.ts +15 -0
  89. package/src/modules/api-versioning/types.ts +86 -0
  90. package/src/modules/api-versioning/versioning.middleware.ts +120 -0
  91. package/src/modules/api-versioning/versioning.routes.ts +54 -0
  92. package/src/modules/api-versioning/versioning.service.ts +189 -0
  93. package/src/modules/audit/audit.repository.ts +206 -0
  94. package/src/modules/audit/audit.service.ts +27 -59
  95. package/src/modules/auth/auth.controller.ts +2 -2
  96. package/src/modules/auth/auth.middleware.ts +3 -9
  97. package/src/modules/auth/auth.routes.ts +10 -107
  98. package/src/modules/auth/auth.service.ts +126 -23
  99. package/src/modules/auth/index.ts +3 -4
  100. package/src/modules/cache/cache.service.ts +367 -0
  101. package/src/modules/cache/index.ts +10 -0
  102. package/src/modules/cache/types.ts +44 -0
  103. package/src/modules/email/email.service.ts +3 -10
  104. package/src/modules/email/templates.ts +2 -8
  105. package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
  106. package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
  107. package/src/modules/feature-flag/feature-flag.service.ts +566 -0
  108. package/src/modules/feature-flag/index.ts +20 -0
  109. package/src/modules/feature-flag/types.ts +192 -0
  110. package/src/modules/i18n/i18n.middleware.ts +186 -0
  111. package/src/modules/i18n/i18n.routes.ts +191 -0
  112. package/src/modules/i18n/i18n.service.ts +456 -0
  113. package/src/modules/i18n/index.ts +18 -0
  114. package/src/modules/i18n/types.ts +118 -0
  115. package/src/modules/media-processing/index.ts +17 -0
  116. package/src/modules/media-processing/media-processing.routes.ts +111 -0
  117. package/src/modules/media-processing/media-processing.service.ts +245 -0
  118. package/src/modules/media-processing/types.ts +156 -0
  119. package/src/modules/mfa/index.ts +20 -0
  120. package/src/modules/mfa/mfa.repository.ts +206 -0
  121. package/src/modules/mfa/mfa.routes.ts +595 -0
  122. package/src/modules/mfa/mfa.service.ts +572 -0
  123. package/src/modules/mfa/totp.ts +150 -0
  124. package/src/modules/mfa/types.ts +57 -0
  125. package/src/modules/notification/index.ts +20 -0
  126. package/src/modules/notification/notification.repository.ts +356 -0
  127. package/src/modules/notification/notification.service.ts +483 -0
  128. package/src/modules/notification/types.ts +119 -0
  129. package/src/modules/oauth/index.ts +20 -0
  130. package/src/modules/oauth/oauth.repository.ts +219 -0
  131. package/src/modules/oauth/oauth.routes.ts +446 -0
  132. package/src/modules/oauth/oauth.service.ts +293 -0
  133. package/src/modules/oauth/providers/apple.provider.ts +250 -0
  134. package/src/modules/oauth/providers/facebook.provider.ts +181 -0
  135. package/src/modules/oauth/providers/github.provider.ts +248 -0
  136. package/src/modules/oauth/providers/google.provider.ts +189 -0
  137. package/src/modules/oauth/providers/twitter.provider.ts +214 -0
  138. package/src/modules/oauth/types.ts +94 -0
  139. package/src/modules/payment/index.ts +19 -0
  140. package/src/modules/payment/payment.repository.ts +733 -0
  141. package/src/modules/payment/payment.routes.ts +390 -0
  142. package/src/modules/payment/payment.service.ts +354 -0
  143. package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
  144. package/src/modules/payment/providers/paypal.provider.ts +190 -0
  145. package/src/modules/payment/providers/stripe.provider.ts +215 -0
  146. package/src/modules/payment/types.ts +140 -0
  147. package/src/modules/queue/cron.ts +438 -0
  148. package/src/modules/queue/index.ts +87 -0
  149. package/src/modules/queue/queue.routes.ts +600 -0
  150. package/src/modules/queue/queue.service.ts +842 -0
  151. package/src/modules/queue/types.ts +222 -0
  152. package/src/modules/queue/workers.ts +366 -0
  153. package/src/modules/rate-limit/index.ts +59 -0
  154. package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
  155. package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
  156. package/src/modules/rate-limit/rate-limit.service.ts +348 -0
  157. package/src/modules/rate-limit/stores/memory.store.ts +165 -0
  158. package/src/modules/rate-limit/stores/redis.store.ts +322 -0
  159. package/src/modules/rate-limit/types.ts +153 -0
  160. package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
  161. package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
  162. package/src/modules/search/adapters/memory.adapter.ts +278 -0
  163. package/src/modules/search/index.ts +21 -0
  164. package/src/modules/search/search.service.ts +234 -0
  165. package/src/modules/search/types.ts +214 -0
  166. package/src/modules/security/index.ts +40 -0
  167. package/src/modules/security/sanitize.ts +223 -0
  168. package/src/modules/security/security-audit.service.ts +388 -0
  169. package/src/modules/security/security.middleware.ts +398 -0
  170. package/src/modules/session/index.ts +3 -0
  171. package/src/modules/session/session.repository.ts +159 -0
  172. package/src/modules/session/session.service.ts +340 -0
  173. package/src/modules/session/types.ts +38 -0
  174. package/src/modules/swagger/index.ts +7 -1
  175. package/src/modules/swagger/schema-builder.ts +16 -4
  176. package/src/modules/swagger/swagger.service.ts +9 -10
  177. package/src/modules/swagger/types.ts +0 -2
  178. package/src/modules/upload/index.ts +14 -0
  179. package/src/modules/upload/types.ts +83 -0
  180. package/src/modules/upload/upload.repository.ts +199 -0
  181. package/src/modules/upload/upload.routes.ts +311 -0
  182. package/src/modules/upload/upload.service.ts +448 -0
  183. package/src/modules/user/index.ts +3 -3
  184. package/src/modules/user/user.controller.ts +15 -9
  185. package/src/modules/user/user.repository.ts +237 -113
  186. package/src/modules/user/user.routes.ts +39 -164
  187. package/src/modules/user/user.service.ts +4 -3
  188. package/src/modules/validation/validator.ts +12 -17
  189. package/src/modules/webhook/index.ts +91 -0
  190. package/src/modules/webhook/retry.ts +196 -0
  191. package/src/modules/webhook/signature.ts +135 -0
  192. package/src/modules/webhook/types.ts +181 -0
  193. package/src/modules/webhook/webhook.repository.ts +358 -0
  194. package/src/modules/webhook/webhook.routes.ts +442 -0
  195. package/src/modules/webhook/webhook.service.ts +457 -0
  196. package/src/modules/websocket/features.ts +504 -0
  197. package/src/modules/websocket/index.ts +106 -0
  198. package/src/modules/websocket/middlewares.ts +298 -0
  199. package/src/modules/websocket/types.ts +181 -0
  200. package/src/modules/websocket/websocket.service.ts +692 -0
  201. package/src/utils/errors.ts +7 -0
  202. package/src/utils/pagination.ts +4 -1
  203. package/tests/helpers/db-check.ts +79 -0
  204. package/tests/integration/auth-redis.test.ts +94 -0
  205. package/tests/integration/cache-redis.test.ts +387 -0
  206. package/tests/integration/mongoose-repositories.test.ts +410 -0
  207. package/tests/integration/payment-prisma.test.ts +637 -0
  208. package/tests/integration/queue-bullmq.test.ts +417 -0
  209. package/tests/integration/user-prisma.test.ts +441 -0
  210. package/tests/integration/websocket-socketio.test.ts +552 -0
  211. package/tests/setup.ts +11 -9
  212. package/vitest.config.ts +3 -8
  213. package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
  214. package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
  215. package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
  216. package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
  217. package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
@@ -0,0 +1,181 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { logger } from '../../../core/logger.js';
3
+ import type { FacebookOAuthConfig, OAuthUser, OAuthTokens, OAuthState } from '../types.js';
4
+
5
+ const FACEBOOK_AUTH_URL = 'https://www.facebook.com/v18.0/dialog/oauth';
6
+ const FACEBOOK_TOKEN_URL = 'https://graph.facebook.com/v18.0/oauth/access_token';
7
+ const FACEBOOK_USERINFO_URL = 'https://graph.facebook.com/v18.0/me';
8
+
9
+ const DEFAULT_SCOPES = ['email', 'public_profile'];
10
+
11
+ export class FacebookOAuthProvider {
12
+ private config: FacebookOAuthConfig;
13
+ private callbackUrl: string;
14
+
15
+ constructor(config: FacebookOAuthConfig, callbackBaseUrl: string) {
16
+ this.config = config;
17
+ this.callbackUrl = `${callbackBaseUrl}/auth/oauth/facebook/callback`;
18
+ }
19
+
20
+ /**
21
+ * Generate authorization URL
22
+ */
23
+ generateAuthUrl(state?: string): { url: string; state: OAuthState } {
24
+ const stateValue = state || randomBytes(32).toString('hex');
25
+ const scopes = this.config.scopes || DEFAULT_SCOPES;
26
+
27
+ const params = new URLSearchParams({
28
+ client_id: this.config.clientId,
29
+ redirect_uri: this.callbackUrl,
30
+ response_type: 'code',
31
+ scope: scopes.join(','),
32
+ state: stateValue,
33
+ auth_type: 'rerequest', // Re-request declined permissions
34
+ });
35
+
36
+ const oauthState: OAuthState = {
37
+ state: stateValue,
38
+ redirectUri: this.callbackUrl,
39
+ createdAt: Date.now(),
40
+ };
41
+
42
+ return {
43
+ url: `${FACEBOOK_AUTH_URL}?${params.toString()}`,
44
+ state: oauthState,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Exchange authorization code for tokens
50
+ */
51
+ async exchangeCode(code: string): Promise<OAuthTokens> {
52
+ const params = new URLSearchParams({
53
+ client_id: this.config.clientId,
54
+ client_secret: this.config.clientSecret,
55
+ code,
56
+ redirect_uri: this.callbackUrl,
57
+ });
58
+
59
+ const response = await fetch(`${FACEBOOK_TOKEN_URL}?${params.toString()}`);
60
+
61
+ if (!response.ok) {
62
+ const error = await response.text();
63
+ logger.error({ error }, 'Facebook token exchange failed');
64
+ throw new Error(`Failed to exchange code: ${error}`);
65
+ }
66
+
67
+ const data = (await response.json()) as {
68
+ access_token: string;
69
+ token_type: string;
70
+ expires_in: number;
71
+ };
72
+
73
+ return {
74
+ accessToken: data.access_token,
75
+ tokenType: data.token_type,
76
+ expiresIn: data.expires_in,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Get user information from Facebook
82
+ */
83
+ async getUser(accessToken: string): Promise<OAuthUser> {
84
+ const fields = 'id,email,name,first_name,last_name,picture.type(large)';
85
+ const url = `${FACEBOOK_USERINFO_URL}?fields=${fields}&access_token=${accessToken}`;
86
+
87
+ const response = await fetch(url);
88
+
89
+ if (!response.ok) {
90
+ const error = await response.text();
91
+ logger.error({ error }, 'Failed to get Facebook user info');
92
+ throw new Error(`Failed to get user info: ${error}`);
93
+ }
94
+
95
+ const data = (await response.json()) as {
96
+ id: string;
97
+ email?: string;
98
+ name: string;
99
+ first_name: string;
100
+ last_name: string;
101
+ picture?: { data: { url: string } };
102
+ };
103
+
104
+ return {
105
+ id: data.id,
106
+ email: data.email || null,
107
+ emailVerified: !!data.email, // Facebook only returns verified emails
108
+ name: data.name,
109
+ firstName: data.first_name,
110
+ lastName: data.last_name,
111
+ picture: data.picture?.data?.url || null,
112
+ provider: 'facebook',
113
+ providerAccountId: data.id,
114
+ accessToken,
115
+ raw: data,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Get long-lived access token (60 days instead of ~2 hours)
121
+ */
122
+ async getLongLivedToken(shortLivedToken: string): Promise<OAuthTokens> {
123
+ const params = new URLSearchParams({
124
+ grant_type: 'fb_exchange_token',
125
+ client_id: this.config.clientId,
126
+ client_secret: this.config.clientSecret,
127
+ fb_exchange_token: shortLivedToken,
128
+ });
129
+
130
+ const response = await fetch(`${FACEBOOK_TOKEN_URL}?${params.toString()}`);
131
+
132
+ if (!response.ok) {
133
+ throw new Error('Failed to get long-lived token');
134
+ }
135
+
136
+ const data = (await response.json()) as {
137
+ access_token: string;
138
+ token_type: string;
139
+ expires_in: number;
140
+ };
141
+
142
+ return {
143
+ accessToken: data.access_token,
144
+ tokenType: data.token_type,
145
+ expiresIn: data.expires_in,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Debug/inspect access token
151
+ */
152
+ async inspectToken(
153
+ accessToken: string
154
+ ): Promise<{ isValid: boolean; userId: string; expiresAt: number }> {
155
+ const appToken = `${this.config.clientId}|${this.config.clientSecret}`;
156
+ const url = `https://graph.facebook.com/debug_token?input_token=${accessToken}&access_token=${appToken}`;
157
+
158
+ const response = await fetch(url);
159
+ const data = (await response.json()) as {
160
+ data: {
161
+ is_valid: boolean;
162
+ user_id: string;
163
+ expires_at: number;
164
+ };
165
+ };
166
+
167
+ return {
168
+ isValid: data.data.is_valid,
169
+ userId: data.data.user_id,
170
+ expiresAt: data.data.expires_at,
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Revoke access (user must re-authorize)
176
+ */
177
+ async revokeAccess(userId: string, accessToken: string): Promise<void> {
178
+ const url = `https://graph.facebook.com/v18.0/${userId}/permissions?access_token=${accessToken}`;
179
+ await fetch(url, { method: 'DELETE' });
180
+ }
181
+ }
@@ -0,0 +1,248 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { logger } from '../../../core/logger.js';
3
+ import type { GitHubOAuthConfig, OAuthUser, OAuthTokens, OAuthState } from '../types.js';
4
+
5
+ const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
6
+ const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
7
+ const GITHUB_API_URL = 'https://api.github.com';
8
+
9
+ const DEFAULT_SCOPES = ['read:user', 'user:email'];
10
+
11
+ export class GitHubOAuthProvider {
12
+ private config: GitHubOAuthConfig;
13
+ private callbackUrl: string;
14
+
15
+ constructor(config: GitHubOAuthConfig, callbackBaseUrl: string) {
16
+ this.config = config;
17
+ this.callbackUrl = `${callbackBaseUrl}/auth/oauth/github/callback`;
18
+ }
19
+
20
+ /**
21
+ * Generate authorization URL
22
+ */
23
+ generateAuthUrl(state?: string): { url: string; state: OAuthState } {
24
+ const stateValue = state || randomBytes(32).toString('hex');
25
+ const scopes = this.config.scopes || DEFAULT_SCOPES;
26
+
27
+ const params = new URLSearchParams({
28
+ client_id: this.config.clientId,
29
+ redirect_uri: this.callbackUrl,
30
+ scope: scopes.join(' '),
31
+ state: stateValue,
32
+ allow_signup: 'true',
33
+ });
34
+
35
+ const oauthState: OAuthState = {
36
+ state: stateValue,
37
+ redirectUri: this.callbackUrl,
38
+ createdAt: Date.now(),
39
+ };
40
+
41
+ return {
42
+ url: `${GITHUB_AUTH_URL}?${params.toString()}`,
43
+ state: oauthState,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Exchange authorization code for tokens
49
+ */
50
+ async exchangeCode(code: string): Promise<OAuthTokens> {
51
+ const response = await fetch(GITHUB_TOKEN_URL, {
52
+ method: 'POST',
53
+ headers: {
54
+ 'Content-Type': 'application/json',
55
+ Accept: 'application/json',
56
+ },
57
+ body: JSON.stringify({
58
+ client_id: this.config.clientId,
59
+ client_secret: this.config.clientSecret,
60
+ code,
61
+ redirect_uri: this.callbackUrl,
62
+ }),
63
+ });
64
+
65
+ if (!response.ok) {
66
+ const error = await response.text();
67
+ logger.error({ error }, 'GitHub token exchange failed');
68
+ throw new Error(`Failed to exchange code: ${error}`);
69
+ }
70
+
71
+ const data = (await response.json()) as {
72
+ access_token: string;
73
+ token_type: string;
74
+ scope: string;
75
+ error?: string;
76
+ error_description?: string;
77
+ };
78
+
79
+ if (data.error) {
80
+ throw new Error(data.error_description || data.error);
81
+ }
82
+
83
+ return {
84
+ accessToken: data.access_token,
85
+ tokenType: data.token_type,
86
+ scope: data.scope,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Get user information from GitHub
92
+ */
93
+ async getUser(accessToken: string): Promise<OAuthUser> {
94
+ // Get user profile
95
+ const userResponse = await fetch(`${GITHUB_API_URL}/user`, {
96
+ headers: {
97
+ Authorization: `Bearer ${accessToken}`,
98
+ Accept: 'application/vnd.github.v3+json',
99
+ },
100
+ });
101
+
102
+ if (!userResponse.ok) {
103
+ const error = await userResponse.text();
104
+ logger.error({ error }, 'Failed to get GitHub user info');
105
+ throw new Error(`Failed to get user info: ${error}`);
106
+ }
107
+
108
+ const userData = (await userResponse.json()) as {
109
+ id: number;
110
+ login: string;
111
+ name: string | null;
112
+ email: string | null;
113
+ avatar_url: string;
114
+ bio: string | null;
115
+ company: string | null;
116
+ location: string | null;
117
+ html_url: string;
118
+ };
119
+
120
+ // Get user emails (email might be private)
121
+ let primaryEmail: string | null = userData.email;
122
+ let emailVerified = false;
123
+
124
+ if (!primaryEmail) {
125
+ const emailsResponse = await fetch(`${GITHUB_API_URL}/user/emails`, {
126
+ headers: {
127
+ Authorization: `Bearer ${accessToken}`,
128
+ Accept: 'application/vnd.github.v3+json',
129
+ },
130
+ });
131
+
132
+ if (emailsResponse.ok) {
133
+ const emails = (await emailsResponse.json()) as Array<{
134
+ email: string;
135
+ primary: boolean;
136
+ verified: boolean;
137
+ }>;
138
+
139
+ const primary = emails.find((e) => e.primary && e.verified);
140
+ if (primary) {
141
+ primaryEmail = primary.email;
142
+ emailVerified = primary.verified;
143
+ } else {
144
+ const verified = emails.find((e) => e.verified);
145
+ if (verified) {
146
+ primaryEmail = verified.email;
147
+ emailVerified = true;
148
+ }
149
+ }
150
+ }
151
+ } else {
152
+ emailVerified = true; // Public emails are verified
153
+ }
154
+
155
+ // Parse name into first/last
156
+ let firstName: string | null = null;
157
+ let lastName: string | null = null;
158
+ if (userData.name) {
159
+ const parts = userData.name.split(' ');
160
+ firstName = parts[0] ?? null;
161
+ lastName = parts.slice(1).join(' ') || null;
162
+ }
163
+
164
+ return {
165
+ id: userData.id.toString(),
166
+ email: primaryEmail,
167
+ emailVerified,
168
+ name: userData.name || userData.login,
169
+ firstName,
170
+ lastName,
171
+ picture: userData.avatar_url,
172
+ provider: 'github',
173
+ providerAccountId: userData.id.toString(),
174
+ accessToken,
175
+ raw: userData,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Get user's repositories
181
+ */
182
+ async getRepositories(
183
+ accessToken: string,
184
+ options: { page?: number; perPage?: number; sort?: 'created' | 'updated' | 'pushed' } = {}
185
+ ): Promise<Array<{ id: number; name: string; fullName: string; private: boolean; url: string }>> {
186
+ const params = new URLSearchParams({
187
+ page: (options.page || 1).toString(),
188
+ per_page: (options.perPage || 30).toString(),
189
+ sort: options.sort || 'updated',
190
+ });
191
+
192
+ const response = await fetch(`${GITHUB_API_URL}/user/repos?${params}`, {
193
+ headers: {
194
+ Authorization: `Bearer ${accessToken}`,
195
+ Accept: 'application/vnd.github.v3+json',
196
+ },
197
+ });
198
+
199
+ if (!response.ok) {
200
+ throw new Error('Failed to get repositories');
201
+ }
202
+
203
+ const repos = (await response.json()) as Array<{
204
+ id: number;
205
+ name: string;
206
+ full_name: string;
207
+ private: boolean;
208
+ html_url: string;
209
+ }>;
210
+
211
+ return repos.map((repo) => ({
212
+ id: repo.id,
213
+ name: repo.name,
214
+ fullName: repo.full_name,
215
+ private: repo.private,
216
+ url: repo.html_url,
217
+ }));
218
+ }
219
+
220
+ /**
221
+ * Check if user has access to a specific repository
222
+ */
223
+ async checkRepoAccess(accessToken: string, owner: string, repo: string): Promise<boolean> {
224
+ const response = await fetch(`${GITHUB_API_URL}/repos/${owner}/${repo}`, {
225
+ headers: {
226
+ Authorization: `Bearer ${accessToken}`,
227
+ Accept: 'application/vnd.github.v3+json',
228
+ },
229
+ });
230
+
231
+ return response.ok;
232
+ }
233
+
234
+ /**
235
+ * Revoke access token
236
+ */
237
+ async revokeToken(accessToken: string): Promise<void> {
238
+ await fetch(`https://api.github.com/applications/${this.config.clientId}/token`, {
239
+ method: 'DELETE',
240
+ headers: {
241
+ Authorization: `Basic ${Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64')}`,
242
+ Accept: 'application/vnd.github.v3+json',
243
+ 'Content-Type': 'application/json',
244
+ },
245
+ body: JSON.stringify({ access_token: accessToken }),
246
+ });
247
+ }
248
+ }
@@ -0,0 +1,189 @@
1
+ import { randomBytes, createHash } from 'crypto';
2
+ import { logger } from '../../../core/logger.js';
3
+ import type { GoogleOAuthConfig, OAuthUser, OAuthTokens, OAuthState } from '../types.js';
4
+
5
+ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
6
+ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
7
+ const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
8
+
9
+ const DEFAULT_SCOPES = [
10
+ 'openid',
11
+ 'https://www.googleapis.com/auth/userinfo.email',
12
+ 'https://www.googleapis.com/auth/userinfo.profile',
13
+ ];
14
+
15
+ export class GoogleOAuthProvider {
16
+ private config: GoogleOAuthConfig;
17
+ private callbackUrl: string;
18
+
19
+ constructor(config: GoogleOAuthConfig, callbackBaseUrl: string) {
20
+ this.config = config;
21
+ this.callbackUrl = `${callbackBaseUrl}/auth/oauth/google/callback`;
22
+ }
23
+
24
+ /**
25
+ * Generate authorization URL with PKCE
26
+ */
27
+ generateAuthUrl(state?: string): { url: string; state: OAuthState } {
28
+ const stateValue = state || randomBytes(32).toString('hex');
29
+ const codeVerifier = randomBytes(32).toString('base64url');
30
+ const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
31
+
32
+ const scopes = this.config.scopes || DEFAULT_SCOPES;
33
+
34
+ const params = new URLSearchParams({
35
+ client_id: this.config.clientId,
36
+ redirect_uri: this.callbackUrl,
37
+ response_type: 'code',
38
+ scope: scopes.join(' '),
39
+ state: stateValue,
40
+ code_challenge: codeChallenge,
41
+ code_challenge_method: 'S256',
42
+ access_type: 'offline',
43
+ prompt: 'consent',
44
+ });
45
+
46
+ const oauthState: OAuthState = {
47
+ state: stateValue,
48
+ codeVerifier,
49
+ redirectUri: this.callbackUrl,
50
+ createdAt: Date.now(),
51
+ };
52
+
53
+ return {
54
+ url: `${GOOGLE_AUTH_URL}?${params.toString()}`,
55
+ state: oauthState,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Exchange authorization code for tokens
61
+ */
62
+ async exchangeCode(code: string, codeVerifier?: string): Promise<OAuthTokens> {
63
+ const params = new URLSearchParams({
64
+ client_id: this.config.clientId,
65
+ client_secret: this.config.clientSecret,
66
+ code,
67
+ grant_type: 'authorization_code',
68
+ redirect_uri: this.callbackUrl,
69
+ });
70
+
71
+ if (codeVerifier) {
72
+ params.append('code_verifier', codeVerifier);
73
+ }
74
+
75
+ const response = await fetch(GOOGLE_TOKEN_URL, {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
78
+ body: params.toString(),
79
+ });
80
+
81
+ if (!response.ok) {
82
+ const error = await response.text();
83
+ logger.error({ error }, 'Google token exchange failed');
84
+ throw new Error(`Failed to exchange code: ${error}`);
85
+ }
86
+
87
+ const data = (await response.json()) as {
88
+ access_token: string;
89
+ refresh_token?: string;
90
+ expires_in: number;
91
+ token_type: string;
92
+ scope: string;
93
+ id_token?: string;
94
+ };
95
+
96
+ return {
97
+ accessToken: data.access_token,
98
+ refreshToken: data.refresh_token,
99
+ expiresIn: data.expires_in,
100
+ tokenType: data.token_type,
101
+ scope: data.scope,
102
+ idToken: data.id_token,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Get user information from Google
108
+ */
109
+ async getUser(accessToken: string): Promise<OAuthUser> {
110
+ const response = await fetch(GOOGLE_USERINFO_URL, {
111
+ headers: { Authorization: `Bearer ${accessToken}` },
112
+ });
113
+
114
+ if (!response.ok) {
115
+ const error = await response.text();
116
+ logger.error({ error }, 'Failed to get Google user info');
117
+ throw new Error(`Failed to get user info: ${error}`);
118
+ }
119
+
120
+ const data = (await response.json()) as {
121
+ id: string;
122
+ email: string;
123
+ verified_email: boolean;
124
+ name: string;
125
+ given_name: string;
126
+ family_name: string;
127
+ picture: string;
128
+ };
129
+
130
+ return {
131
+ id: data.id,
132
+ email: data.email,
133
+ emailVerified: data.verified_email,
134
+ name: data.name,
135
+ firstName: data.given_name,
136
+ lastName: data.family_name,
137
+ picture: data.picture,
138
+ provider: 'google',
139
+ providerAccountId: data.id,
140
+ accessToken,
141
+ raw: data,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Refresh access token
147
+ */
148
+ async refreshToken(refreshToken: string): Promise<OAuthTokens> {
149
+ const params = new URLSearchParams({
150
+ client_id: this.config.clientId,
151
+ client_secret: this.config.clientSecret,
152
+ refresh_token: refreshToken,
153
+ grant_type: 'refresh_token',
154
+ });
155
+
156
+ const response = await fetch(GOOGLE_TOKEN_URL, {
157
+ method: 'POST',
158
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
159
+ body: params.toString(),
160
+ });
161
+
162
+ if (!response.ok) {
163
+ throw new Error('Failed to refresh token');
164
+ }
165
+
166
+ const data = (await response.json()) as {
167
+ access_token: string;
168
+ expires_in: number;
169
+ token_type: string;
170
+ scope: string;
171
+ };
172
+
173
+ return {
174
+ accessToken: data.access_token,
175
+ expiresIn: data.expires_in,
176
+ tokenType: data.token_type,
177
+ scope: data.scope,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Revoke access token
183
+ */
184
+ async revokeToken(token: string): Promise<void> {
185
+ await fetch(`https://oauth2.googleapis.com/revoke?token=${token}`, {
186
+ method: 'POST',
187
+ });
188
+ }
189
+ }