create-stackr 0.2.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 (274) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +642 -0
  3. package/bin/cli.js +12 -0
  4. package/dist/cli.d.ts +3 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +113 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/config/dependencies.d.ts +82 -0
  9. package/dist/config/dependencies.d.ts.map +1 -0
  10. package/dist/config/dependencies.js +82 -0
  11. package/dist/config/dependencies.js.map +1 -0
  12. package/dist/config/presets.d.ts +3 -0
  13. package/dist/config/presets.d.ts.map +1 -0
  14. package/dist/config/presets.js +174 -0
  15. package/dist/config/presets.js.map +1 -0
  16. package/dist/generators/index.d.ts +40 -0
  17. package/dist/generators/index.d.ts.map +1 -0
  18. package/dist/generators/index.js +130 -0
  19. package/dist/generators/index.js.map +1 -0
  20. package/dist/generators/onboarding.d.ts +8 -0
  21. package/dist/generators/onboarding.d.ts.map +1 -0
  22. package/dist/generators/onboarding.js +141 -0
  23. package/dist/generators/onboarding.js.map +1 -0
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +65 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/prompts/features.d.ts +14 -0
  29. package/dist/prompts/features.d.ts.map +1 -0
  30. package/dist/prompts/features.js +96 -0
  31. package/dist/prompts/features.js.map +1 -0
  32. package/dist/prompts/index.d.ts +3 -0
  33. package/dist/prompts/index.d.ts.map +1 -0
  34. package/dist/prompts/index.js +93 -0
  35. package/dist/prompts/index.js.map +1 -0
  36. package/dist/prompts/onboarding.d.ts +6 -0
  37. package/dist/prompts/onboarding.d.ts.map +1 -0
  38. package/dist/prompts/onboarding.js +37 -0
  39. package/dist/prompts/onboarding.js.map +1 -0
  40. package/dist/prompts/orm.d.ts +3 -0
  41. package/dist/prompts/orm.d.ts.map +1 -0
  42. package/dist/prompts/orm.js +23 -0
  43. package/dist/prompts/orm.js.map +1 -0
  44. package/dist/prompts/packageManager.d.ts +2 -0
  45. package/dist/prompts/packageManager.d.ts.map +1 -0
  46. package/dist/prompts/packageManager.js +18 -0
  47. package/dist/prompts/packageManager.js.map +1 -0
  48. package/dist/prompts/platform.d.ts +3 -0
  49. package/dist/prompts/platform.d.ts.map +1 -0
  50. package/dist/prompts/platform.js +21 -0
  51. package/dist/prompts/platform.js.map +1 -0
  52. package/dist/prompts/preset.d.ts +4 -0
  53. package/dist/prompts/preset.d.ts.map +1 -0
  54. package/dist/prompts/preset.js +165 -0
  55. package/dist/prompts/preset.js.map +1 -0
  56. package/dist/prompts/project.d.ts +2 -0
  57. package/dist/prompts/project.d.ts.map +1 -0
  58. package/dist/prompts/project.js +27 -0
  59. package/dist/prompts/project.js.map +1 -0
  60. package/dist/prompts/sdks.d.ts +2 -0
  61. package/dist/prompts/sdks.d.ts.map +1 -0
  62. package/dist/prompts/sdks.js +46 -0
  63. package/dist/prompts/sdks.js.map +1 -0
  64. package/dist/types/index.d.ts +77 -0
  65. package/dist/types/index.d.ts.map +1 -0
  66. package/dist/types/index.js +25 -0
  67. package/dist/types/index.js.map +1 -0
  68. package/dist/utils/cleanup.d.ts +5 -0
  69. package/dist/utils/cleanup.d.ts.map +1 -0
  70. package/dist/utils/cleanup.js +38 -0
  71. package/dist/utils/cleanup.js.map +1 -0
  72. package/dist/utils/copy.d.ts +10 -0
  73. package/dist/utils/copy.d.ts.map +1 -0
  74. package/dist/utils/copy.js +53 -0
  75. package/dist/utils/copy.js.map +1 -0
  76. package/dist/utils/errors.d.ts +33 -0
  77. package/dist/utils/errors.d.ts.map +1 -0
  78. package/dist/utils/errors.js +136 -0
  79. package/dist/utils/errors.js.map +1 -0
  80. package/dist/utils/git.d.ts +5 -0
  81. package/dist/utils/git.d.ts.map +1 -0
  82. package/dist/utils/git.js +33 -0
  83. package/dist/utils/git.js.map +1 -0
  84. package/dist/utils/logger.d.ts +9 -0
  85. package/dist/utils/logger.d.ts.map +1 -0
  86. package/dist/utils/logger.js +22 -0
  87. package/dist/utils/logger.js.map +1 -0
  88. package/dist/utils/package.d.ts +16 -0
  89. package/dist/utils/package.d.ts.map +1 -0
  90. package/dist/utils/package.js +86 -0
  91. package/dist/utils/package.js.map +1 -0
  92. package/dist/utils/system-validation.d.ts +9 -0
  93. package/dist/utils/system-validation.d.ts.map +1 -0
  94. package/dist/utils/system-validation.js +31 -0
  95. package/dist/utils/system-validation.js.map +1 -0
  96. package/dist/utils/template.d.ts +20 -0
  97. package/dist/utils/template.d.ts.map +1 -0
  98. package/dist/utils/template.js +234 -0
  99. package/dist/utils/template.js.map +1 -0
  100. package/dist/utils/validation.d.ts +8 -0
  101. package/dist/utils/validation.d.ts.map +1 -0
  102. package/dist/utils/validation.js +94 -0
  103. package/dist/utils/validation.js.map +1 -0
  104. package/package.json +96 -0
  105. package/templates/base/backend/.dockerignore.ejs +62 -0
  106. package/templates/base/backend/.env.example.ejs +116 -0
  107. package/templates/base/backend/Dockerfile.ejs +142 -0
  108. package/templates/base/backend/controllers/event-queue/index.ts +20 -0
  109. package/templates/base/backend/controllers/event-queue/workers/user.ts +39 -0
  110. package/templates/base/backend/controllers/rest-api/index.ts +48 -0
  111. package/templates/base/backend/controllers/rest-api/plugins/auth.ts +152 -0
  112. package/templates/base/backend/controllers/rest-api/plugins/config.ts +64 -0
  113. package/templates/base/backend/controllers/rest-api/plugins/error-handler.ts +118 -0
  114. package/templates/base/backend/controllers/rest-api/routes/auth.ts.ejs +180 -0
  115. package/templates/base/backend/controllers/rest-api/routes/device-sessions.ts +197 -0
  116. package/templates/base/backend/controllers/rest-api/routes/oauth-web.ts.ejs +375 -0
  117. package/templates/base/backend/controllers/rest-api/server.ts.ejs +87 -0
  118. package/templates/base/backend/domain/device-session/repository.drizzle.ts +209 -0
  119. package/templates/base/backend/domain/device-session/repository.prisma.ts +248 -0
  120. package/templates/base/backend/domain/device-session/schema.ts +72 -0
  121. package/templates/base/backend/domain/session/repository.drizzle.ts +72 -0
  122. package/templates/base/backend/domain/session/repository.prisma.ts +72 -0
  123. package/templates/base/backend/domain/session/schema.ts +29 -0
  124. package/templates/base/backend/domain/user/repository.drizzle.ts +127 -0
  125. package/templates/base/backend/domain/user/repository.prisma.ts +115 -0
  126. package/templates/base/backend/domain/user/schema.ts +14 -0
  127. package/templates/base/backend/drizzle/schema.drizzle.ts +111 -0
  128. package/templates/base/backend/drizzle.config.drizzle.ts +13 -0
  129. package/templates/base/backend/lib/auth.drizzle.ts.ejs +104 -0
  130. package/templates/base/backend/lib/auth.prisma.ts.ejs +97 -0
  131. package/templates/base/backend/lib/constants.ts.ejs +29 -0
  132. package/templates/base/backend/package.json.ejs +50 -0
  133. package/templates/base/backend/prisma/schema.prisma.ejs +102 -0
  134. package/templates/base/backend/prisma.config.prisma.ts +12 -0
  135. package/templates/base/backend/tsconfig.json +39 -0
  136. package/templates/base/backend/utils/db.drizzle.ts +41 -0
  137. package/templates/base/backend/utils/db.prisma.ts +51 -0
  138. package/templates/base/backend/utils/email.ts.ejs +35 -0
  139. package/templates/base/backend/utils/errors.ts +348 -0
  140. package/templates/base/backend/utils/redis.ts.ejs +279 -0
  141. package/templates/base/mobile/.env.example.ejs +35 -0
  142. package/templates/base/mobile/.gitignore.ejs +167 -0
  143. package/templates/base/mobile/app/+not-found.tsx +85 -0
  144. package/templates/base/mobile/app/_layout.tsx.ejs +71 -0
  145. package/templates/base/mobile/app.json.ejs +88 -0
  146. package/templates/base/mobile/assets/images/adaptive-icon.png +0 -0
  147. package/templates/base/mobile/assets/images/favicon.png +0 -0
  148. package/templates/base/mobile/assets/images/icon.png +0 -0
  149. package/templates/base/mobile/assets/images/onboarding_page_1.png +0 -0
  150. package/templates/base/mobile/assets/images/onboarding_page_2.png +0 -0
  151. package/templates/base/mobile/assets/images/onboarding_page_3.png +0 -0
  152. package/templates/base/mobile/assets/images/paywall_image.png +0 -0
  153. package/templates/base/mobile/assets/images/splash.png +0 -0
  154. package/templates/base/mobile/eas.json.ejs +49 -0
  155. package/templates/base/mobile/metro.config.js +9 -0
  156. package/templates/base/mobile/package.json.ejs +53 -0
  157. package/templates/base/mobile/src/components/ui/Button.tsx +131 -0
  158. package/templates/base/mobile/src/components/ui/Card.tsx +68 -0
  159. package/templates/base/mobile/src/components/ui/IconSymbol.tsx +90 -0
  160. package/templates/base/mobile/src/components/ui/Input.tsx +142 -0
  161. package/templates/base/mobile/src/components/ui/LoadingSpinner.tsx +98 -0
  162. package/templates/base/mobile/src/components/ui/OnboardingLayout.tsx +356 -0
  163. package/templates/base/mobile/src/components/ui/PaywallLayout.tsx +311 -0
  164. package/templates/base/mobile/src/components/ui/Skeleton.tsx +58 -0
  165. package/templates/base/mobile/src/components/ui/index.ts +6 -0
  166. package/templates/base/mobile/src/constants/Theme.ts +163 -0
  167. package/templates/base/mobile/src/context/ThemeContext.tsx +157 -0
  168. package/templates/base/mobile/src/lib/auth-client.ts.ejs +51 -0
  169. package/templates/base/mobile/src/services/api.ts.ejs +71 -0
  170. package/templates/base/mobile/src/services/errorService.ts +179 -0
  171. package/templates/base/mobile/src/services/sdkInitializer.ts.ejs +36 -0
  172. package/templates/base/mobile/src/store/index.ts.ejs +18 -0
  173. package/templates/base/mobile/src/store/ui.store.ts +100 -0
  174. package/templates/base/mobile/src/utils/formatters.ts +105 -0
  175. package/templates/base/mobile/src/utils/logger.ts +73 -0
  176. package/templates/base/mobile/src/utils/responsive.ts +234 -0
  177. package/templates/base/mobile/tsconfig.json +32 -0
  178. package/templates/base/web/.env.example.ejs +26 -0
  179. package/templates/base/web/components.json +22 -0
  180. package/templates/base/web/eslint.config.mjs +18 -0
  181. package/templates/base/web/next.config.ts +7 -0
  182. package/templates/base/web/package.json.ejs +35 -0
  183. package/templates/base/web/postcss.config.mjs +7 -0
  184. package/templates/base/web/public/.gitkeep +0 -0
  185. package/templates/base/web/public/file.svg +1 -0
  186. package/templates/base/web/public/globe.svg +1 -0
  187. package/templates/base/web/public/next.svg +1 -0
  188. package/templates/base/web/public/vercel.svg +1 -0
  189. package/templates/base/web/public/window.svg +1 -0
  190. package/templates/base/web/src/app/favicon.ico +0 -0
  191. package/templates/base/web/src/app/globals.css +152 -0
  192. package/templates/base/web/src/app/layout.tsx.ejs +54 -0
  193. package/templates/base/web/src/app/page.tsx.ejs +92 -0
  194. package/templates/base/web/src/components/auth/auth-hydrator.tsx.ejs +19 -0
  195. package/templates/base/web/src/components/auth/protected-route.tsx.ejs +109 -0
  196. package/templates/base/web/src/components/providers/device-session-setup.tsx.ejs +56 -0
  197. package/templates/base/web/src/components/providers/theme-provider.tsx +17 -0
  198. package/templates/base/web/src/components/theme-toggle.tsx +34 -0
  199. package/templates/base/web/src/components/ui/button.tsx +62 -0
  200. package/templates/base/web/src/components/ui/card.tsx +92 -0
  201. package/templates/base/web/src/components/ui/input.tsx +21 -0
  202. package/templates/base/web/src/components/ui/label.tsx +24 -0
  203. package/templates/base/web/src/components/ui/skeleton.tsx +13 -0
  204. package/templates/base/web/src/components/ui/spinner.tsx +20 -0
  205. package/templates/base/web/src/hooks/use-device-session.ts.ejs +40 -0
  206. package/templates/base/web/src/hooks/use-session.ts.ejs +56 -0
  207. package/templates/base/web/src/lib/auth/actions.ts.ejs +334 -0
  208. package/templates/base/web/src/lib/auth/config.ts.ejs +65 -0
  209. package/templates/base/web/src/lib/auth/cookies.ts.ejs +74 -0
  210. package/templates/base/web/src/lib/auth/index.ts.ejs +40 -0
  211. package/templates/base/web/src/lib/auth/oauth.ts.ejs +72 -0
  212. package/templates/base/web/src/lib/auth/pkce.ts.ejs +48 -0
  213. package/templates/base/web/src/lib/auth/sessions.ts.ejs +135 -0
  214. package/templates/base/web/src/lib/auth/user-agent.ts.ejs +47 -0
  215. package/templates/base/web/src/lib/device/actions.ts.ejs +148 -0
  216. package/templates/base/web/src/lib/device/id.ts.ejs +74 -0
  217. package/templates/base/web/src/lib/utils.ts +6 -0
  218. package/templates/base/web/src/proxy.ts.ejs +66 -0
  219. package/templates/base/web/src/store/auth.store.ts.ejs +89 -0
  220. package/templates/base/web/src/store/deviceSession.store.ts.ejs +141 -0
  221. package/templates/base/web/tsconfig.json +34 -0
  222. package/templates/features/mobile/auth/app/(auth)/_layout.tsx +16 -0
  223. package/templates/features/mobile/auth/app/(auth)/login.tsx +86 -0
  224. package/templates/features/mobile/auth/app/(auth)/register.tsx +86 -0
  225. package/templates/features/mobile/auth/components/auth/LoginForm.tsx.ejs +349 -0
  226. package/templates/features/mobile/auth/components/auth/RegisterForm.tsx.ejs +407 -0
  227. package/templates/features/mobile/auth/components/auth/index.ts +2 -0
  228. package/templates/features/mobile/auth/hooks/index.ts.ejs +1 -0
  229. package/templates/features/mobile/auth/hooks/useAuth.ts.ejs +367 -0
  230. package/templates/features/mobile/auth/services/deviceSession.ts +370 -0
  231. package/templates/features/mobile/auth/store/deviceSession.store.ts +326 -0
  232. package/templates/features/mobile/onboarding/app/(onboarding)/_layout.tsx.ejs +11 -0
  233. package/templates/features/mobile/onboarding/app/(onboarding)/page-1.tsx.ejs +52 -0
  234. package/templates/features/mobile/onboarding/app/(onboarding)/page-2.tsx.ejs +52 -0
  235. package/templates/features/mobile/onboarding/app/(onboarding)/page-3.tsx.ejs +60 -0
  236. package/templates/features/mobile/paywall/app/paywall.tsx +550 -0
  237. package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx +26 -0
  238. package/templates/features/mobile/tabs/app/(tabs)/index.tsx +565 -0
  239. package/templates/features/web/.gitkeep +0 -0
  240. package/templates/features/web/auth/app/(app)/dashboard/dashboard-client.tsx.ejs +166 -0
  241. package/templates/features/web/auth/app/(app)/dashboard/page.tsx.ejs +24 -0
  242. package/templates/features/web/auth/app/(app)/layout.tsx.ejs +43 -0
  243. package/templates/features/web/auth/app/(app)/settings/sessions/page.tsx.ejs +29 -0
  244. package/templates/features/web/auth/app/(app)/settings/sessions/sessions-client.tsx.ejs +77 -0
  245. package/templates/features/web/auth/app/(auth)/forgot-password/page.tsx.ejs +127 -0
  246. package/templates/features/web/auth/app/(auth)/layout.tsx.ejs +32 -0
  247. package/templates/features/web/auth/app/(auth)/login/page.tsx.ejs +35 -0
  248. package/templates/features/web/auth/app/(auth)/register/page.tsx.ejs +19 -0
  249. package/templates/features/web/auth/app/(auth)/reset-password/page.tsx.ejs +40 -0
  250. package/templates/features/web/auth/app/(auth)/verify-email/page.tsx.ejs +198 -0
  251. package/templates/features/web/auth/app/auth/callback/route.ts.ejs +152 -0
  252. package/templates/features/web/auth/components/auth/login-form.tsx.ejs +100 -0
  253. package/templates/features/web/auth/components/auth/oauth-buttons.tsx.ejs +126 -0
  254. package/templates/features/web/auth/components/auth/password-reset-form.tsx.ejs +103 -0
  255. package/templates/features/web/auth/components/auth/register-form.tsx.ejs +139 -0
  256. package/templates/features/web/auth/components/settings/session-card.tsx.ejs +132 -0
  257. package/templates/integrations/mobile/adjust/services/adjustService.ts.ejs +163 -0
  258. package/templates/integrations/mobile/adjust/store/adjust.store.ts +243 -0
  259. package/templates/integrations/mobile/att/services/attService.ts +84 -0
  260. package/templates/integrations/mobile/att/services/trackingPermissions.ts +208 -0
  261. package/templates/integrations/mobile/att/store/att.store.ts +162 -0
  262. package/templates/integrations/mobile/revenuecat/services/revenuecatService.ts.ejs +174 -0
  263. package/templates/integrations/mobile/revenuecat/store/revenuecat.store.ts +286 -0
  264. package/templates/integrations/mobile/scate/services/scateService.ts.ejs +85 -0
  265. package/templates/integrations/mobile/scate/store/scate.store.ts +125 -0
  266. package/templates/integrations/web/.gitkeep +0 -0
  267. package/templates/shared/.env.example.ejs +21 -0
  268. package/templates/shared/.gitignore.ejs +145 -0
  269. package/templates/shared/README.md.ejs +134 -0
  270. package/templates/shared/docker-compose.prod.yml.ejs +120 -0
  271. package/templates/shared/docker-compose.yml.ejs +129 -0
  272. package/templates/shared/scripts/docker-dev.sh.ejs +395 -0
  273. package/templates/shared/scripts/docker-prod.sh.ejs +542 -0
  274. package/templates/shared/scripts/setup.sh.ejs +979 -0
@@ -0,0 +1,375 @@
1
+ import { FastifyPluginAsync } from 'fastify';
2
+ import crypto from 'crypto';
3
+ import { oauthStore, isRedisAvailable, isUsingMemoryFallback } from '../../../utils/redis';
4
+ import { auth } from '../../../lib/auth';
5
+ import { BETTER_AUTH_COOKIE_NAME } from '../../../lib/constants';
6
+ import { Type } from '@sinclair/typebox';
7
+
8
+ /**
9
+ * Web OAuth routes with PKCE support
10
+ *
11
+ * These routes wrap Better Auth's OAuth flow to support the BFF pattern
12
+ * where Next.js manages cookies and Fastify manages sessions.
13
+ *
14
+ * FLOW:
15
+ * 1. /init - Store PKCE challenge, call Better Auth to get OAuth URL, redirect
16
+ * 2. OAuth provider authenticates user
17
+ * 3. Better Auth /api/auth/callback/:provider handles OAuth callback
18
+ * 4. /callback/:provider - Read session from cookies (set by Better Auth), create exchange token
19
+ * 5. /exchange - Verify PKCE, return session token
20
+ */
21
+ const oauthWebRoutes: FastifyPluginAsync = async (server) => {
22
+ // Health check for OAuth subsystem
23
+ server.get('/health', async (request, reply) => {
24
+ const redisOk = isRedisAvailable();
25
+ return reply.status(redisOk ? 200 : 503).send({
26
+ status: redisOk ? 'ok' : 'degraded',
27
+ redis: redisOk,
28
+ usingMemoryFallback: isUsingMemoryFallback(),
29
+ timestamp: new Date().toISOString(),
30
+ });
31
+ });
32
+
33
+ /**
34
+ * Step 1: Initialize OAuth flow
35
+ *
36
+ * Called by Next.js server action after setting PKCE cookies.
37
+ * Stores PKCE challenge in Redis and redirects to OAuth provider via Better Auth.
38
+ */
39
+ server.get(
40
+ '/init',
41
+ {
42
+ schema: {
43
+ querystring: Type.Object({
44
+ provider: Type.Union([
45
+ Type.Literal('google'),
46
+ Type.Literal('apple'),
47
+ Type.Literal('github'),
48
+ ]),
49
+ code_challenge: Type.String({ minLength: 43, maxLength: 128 }),
50
+ code_challenge_method: Type.Literal('S256'),
51
+ state: Type.String({ pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }),
52
+ callback_url: Type.String(),
53
+ }),
54
+ },
55
+ },
56
+ async (request, reply) => {
57
+ const { provider, code_challenge, state, callback_url } = request.query as {
58
+ provider: 'google' | 'apple' | 'github';
59
+ code_challenge: string;
60
+ state: string;
61
+ callback_url: string;
62
+ };
63
+
64
+ // Check Redis availability
65
+ if (!isRedisAvailable()) {
66
+ server.log.error('Redis unavailable during OAuth init');
67
+ return reply.redirect(`${callback_url}?error=service_unavailable`);
68
+ }
69
+
70
+ // Validate callback URL is from allowed origins
71
+ const allowedOrigins = (process.env.WEB_APP_URL || 'http://localhost:3000')
72
+ .split(',')
73
+ .map((o) => o.trim());
74
+
75
+ let callbackOrigin: string;
76
+ try {
77
+ callbackOrigin = new URL(callback_url).origin;
78
+ } catch {
79
+ return reply.status(400).send({ error: 'Invalid callback URL' });
80
+ }
81
+
82
+ if (!allowedOrigins.some((origin) => callbackOrigin === origin)) {
83
+ server.log.warn(
84
+ `OAuth init rejected: callback origin ${callbackOrigin} not in allowed list`
85
+ );
86
+ return reply.status(400).send({ error: 'Invalid callback URL origin' });
87
+ }
88
+
89
+ try {
90
+ // Store PKCE challenge in store (Redis or in-memory fallback in dev)
91
+ await oauthStore.storeOAuthState(state, {
92
+ code_challenge,
93
+ callback_url,
94
+ provider,
95
+ });
96
+
97
+ if (isUsingMemoryFallback()) {
98
+ server.log.warn('OAuth state stored in memory - will be lost on server restart');
99
+ }
100
+ } catch (err) {
101
+ server.log.error({ err }, 'Failed to store OAuth state');
102
+ return reply.redirect(`${callback_url}?error=service_unavailable`);
103
+ }
104
+
105
+ // Build the callback URL for Better Auth that points to our callback handler
106
+ // Include web_state so we can retrieve PKCE data later
107
+ const backendUrl = process.env.BETTER_AUTH_URL || `${request.protocol}://${request.hostname}`;
108
+ const internalCallback = `${backendUrl}/api/auth/web/oauth/callback/${provider}?web_state=${state}`;
109
+
110
+ try {
111
+ // Use Better Auth's server-side API to get the OAuth redirect URL
112
+ // This properly handles OAuth state, nonce, and provider-specific configuration
113
+ //
114
+ // CRITICAL: We must forward the Set-Cookie headers from Better Auth to the browser.
115
+ // Better Auth stores a state value in a cookie during OAuth init, and verifies it
116
+ // on callback. Without forwarding these cookies, we get state_mismatch errors.
117
+ //
118
+ // We use returnHeaders: true to get both the response data and headers.
119
+ // See: https://www.better-auth.com/docs/concepts/api
120
+ const { headers, response } = await auth.api.signInSocial({
121
+ returnHeaders: true,
122
+ body: {
123
+ provider,
124
+ callbackURL: internalCallback,
125
+ },
126
+ });
127
+
128
+ // Forward Set-Cookie headers from Better Auth to the browser
129
+ // This ensures the OAuth state cookie is properly set before redirecting to Google
130
+ const setCookies = headers.getSetCookie?.() ?? [];
131
+ for (const cookie of setCookies) {
132
+ reply.header('Set-Cookie', cookie);
133
+ }
134
+
135
+ if (setCookies.length > 0) {
136
+ server.log.debug(
137
+ { cookieCount: setCookies.length },
138
+ 'Forwarded Better Auth cookies to browser'
139
+ );
140
+ }
141
+
142
+ // Extract redirect URL from response
143
+ // The response object contains { url, redirect } when using returnHeaders
144
+ const responseData = response as { url?: string; redirect?: boolean } | null;
145
+ const redirectUrl = responseData?.url;
146
+
147
+ if (redirectUrl) {
148
+ return reply.redirect(redirectUrl);
149
+ }
150
+
151
+ server.log.error('Better Auth signInSocial did not return a redirect URL');
152
+ return reply.redirect(`${callback_url}?error=oauth_init_failed`);
153
+ } catch (err) {
154
+ server.log.error({ err }, 'Failed to initiate OAuth with Better Auth');
155
+ // Clean up stored state on failure
156
+ await oauthStore.deleteOAuthState(state).catch(() => {});
157
+ return reply.redirect(`${callback_url}?error=oauth_init_failed`);
158
+ }
159
+ }
160
+ );
161
+
162
+ /**
163
+ * Step 2: Handle OAuth callback from Better Auth
164
+ *
165
+ * IMPORTANT: This is called AFTER Better Auth has:
166
+ * 1. Received the callback from the OAuth provider
167
+ * 2. Exchanged the auth code for tokens
168
+ * 3. Created/updated the user in the database
169
+ * 4. Created a session
170
+ * 5. Set the session cookie
171
+ * 6. Redirected to this URL
172
+ *
173
+ * The session token is already in the REQUEST COOKIES at this point.
174
+ * We do NOT call Better Auth's handler again.
175
+ */
176
+ server.get(
177
+ '/callback/:provider',
178
+ {
179
+ schema: {
180
+ params: Type.Object({
181
+ provider: Type.String(),
182
+ }),
183
+ querystring: Type.Object({
184
+ web_state: Type.Optional(Type.String({ pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' })),
185
+ // OAuth error params (returned by provider on failure)
186
+ error: Type.Optional(Type.String()),
187
+ error_description: Type.Optional(Type.String()),
188
+ }),
189
+ },
190
+ },
191
+ async (request, reply) => {
192
+ const { provider } = request.params as { provider: string };
193
+ const { web_state, error, error_description } = request.query as {
194
+ web_state?: string;
195
+ error?: string;
196
+ error_description?: string;
197
+ };
198
+
199
+ // Default error redirect
200
+ const defaultErrorUrl = `${process.env.WEB_APP_URL || 'http://localhost:3000'}/login`;
201
+
202
+ // Handle OAuth provider errors (e.g., user denied access)
203
+ if (error) {
204
+ server.log.warn(
205
+ { provider, error, error_description },
206
+ 'OAuth provider returned an error'
207
+ );
208
+ return reply.redirect(
209
+ `${defaultErrorUrl}?error=oauth_${error}${error_description ? `&message=${encodeURIComponent(error_description)}` : ''}`
210
+ );
211
+ }
212
+
213
+ // Validate required web_state parameter
214
+ if (!web_state) {
215
+ server.log.warn('OAuth callback: missing web_state parameter');
216
+ return reply.redirect(`${defaultErrorUrl}?error=invalid_callback`);
217
+ }
218
+
219
+ // Check Redis availability
220
+ if (!isRedisAvailable()) {
221
+ server.log.error('Redis unavailable during OAuth callback');
222
+ return reply.redirect(`${defaultErrorUrl}?error=service_unavailable`);
223
+ }
224
+
225
+ // Retrieve PKCE data from store
226
+ let pkceData;
227
+ try {
228
+ pkceData = await oauthStore.getOAuthState(web_state);
229
+ } catch (err) {
230
+ server.log.error({ err }, 'Failed to retrieve OAuth state from store');
231
+ return reply.redirect(`${defaultErrorUrl}?error=service_unavailable`);
232
+ }
233
+
234
+ if (!pkceData) {
235
+ server.log.warn(`OAuth callback: invalid or expired state ${web_state}`);
236
+ return reply.redirect(`${defaultErrorUrl}?error=invalid_state`);
237
+ }
238
+
239
+ // CRITICAL: Read session token from REQUEST cookies
240
+ // Better Auth has already set this cookie when it processed the OAuth callback
241
+ const sessionToken = extractSessionTokenFromCookies(request.headers.cookie);
242
+
243
+ if (!sessionToken) {
244
+ server.log.warn('OAuth callback: no session token in cookies');
245
+ await oauthStore.deleteOAuthState(web_state);
246
+ return reply.redirect(`${pkceData.callback_url}?error=no_session`);
247
+ }
248
+
249
+ // Generate exchange token
250
+ const exchangeToken = crypto.randomUUID();
251
+
252
+ try {
253
+ // Store exchange token with session and challenge
254
+ await oauthStore.storeExchangeToken(exchangeToken, {
255
+ session_token: sessionToken,
256
+ code_challenge: pkceData.code_challenge,
257
+ });
258
+ } catch (err) {
259
+ server.log.error({ err }, 'Failed to store exchange token');
260
+ await oauthStore.deleteOAuthState(web_state);
261
+ return reply.redirect(`${pkceData.callback_url}?error=service_unavailable`);
262
+ }
263
+
264
+ // Clean up OAuth state
265
+ await oauthStore.deleteOAuthState(web_state);
266
+
267
+ // Redirect to Next.js callback with exchange token
268
+ const redirectUrl = new URL(pkceData.callback_url);
269
+ redirectUrl.searchParams.set('exchange_token', exchangeToken);
270
+ redirectUrl.searchParams.set('state', web_state);
271
+
272
+ server.log.info(
273
+ { provider, state: web_state },
274
+ 'OAuth callback successful, redirecting with exchange token'
275
+ );
276
+ return reply.redirect(redirectUrl.toString());
277
+ }
278
+ );
279
+
280
+ /**
281
+ * Step 3: Exchange token for session with PKCE verification
282
+ *
283
+ * Called by Next.js server to exchange the short-lived token for a session.
284
+ * Verifies PKCE challenge to prevent token interception attacks.
285
+ */
286
+ server.post(
287
+ '/exchange',
288
+ {
289
+ schema: {
290
+ body: Type.Object({
291
+ exchange_token: Type.String({ pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }),
292
+ code_verifier: Type.String({ minLength: 43, maxLength: 128 }),
293
+ }),
294
+ response: {
295
+ 200: Type.Object({
296
+ session_token: Type.String(),
297
+ }),
298
+ 400: Type.Object({
299
+ error: Type.String(),
300
+ }),
301
+ 503: Type.Object({
302
+ error: Type.String(),
303
+ }),
304
+ },
305
+ },
306
+ },
307
+ async (request, reply) => {
308
+ const { exchange_token, code_verifier } = request.body as {
309
+ exchange_token: string;
310
+ code_verifier: string;
311
+ };
312
+
313
+ // Check Redis availability
314
+ if (!isRedisAvailable()) {
315
+ return reply.status(503).send({ error: 'Service temporarily unavailable' });
316
+ }
317
+
318
+ // Retrieve and consume exchange token (single use)
319
+ let exchangeData;
320
+ try {
321
+ exchangeData = await oauthStore.consumeExchangeToken(exchange_token);
322
+ } catch (err) {
323
+ server.log.error({ err }, 'Failed to retrieve exchange token from store');
324
+ return reply.status(503).send({ error: 'Service temporarily unavailable' });
325
+ }
326
+
327
+ if (!exchangeData) {
328
+ server.log.warn('Token exchange: invalid or expired exchange token');
329
+ return reply.status(400).send({ error: 'Invalid or expired exchange token' });
330
+ }
331
+
332
+ // Verify PKCE: SHA256(verifier) should equal stored challenge
333
+ const computedChallenge = crypto
334
+ .createHash('sha256')
335
+ .update(code_verifier)
336
+ .digest('base64url');
337
+
338
+ if (computedChallenge !== exchangeData.code_challenge) {
339
+ // PKCE verification failed - possible interception attempt
340
+ server.log.warn('Token exchange: PKCE verification failed');
341
+ return reply.status(400).send({ error: 'PKCE verification failed' });
342
+ }
343
+
344
+ // PKCE verified! Return session token
345
+ server.log.info('Token exchange successful');
346
+ return reply.send({ session_token: exchangeData.session_token });
347
+ }
348
+ );
349
+ };
350
+
351
+ /**
352
+ * Extract Better Auth session token from cookie header
353
+ *
354
+ * @param cookieHeader - The Cookie header string from the request
355
+ * @returns The session token or null if not found
356
+ */
357
+ function extractSessionTokenFromCookies(cookieHeader: string | undefined): string | null {
358
+ if (!cookieHeader) return null;
359
+
360
+ // Parse cookies (simple parser, handles most cases)
361
+ const cookies = cookieHeader.split(';').reduce(
362
+ (acc, cookie) => {
363
+ const [key, ...valueParts] = cookie.trim().split('=');
364
+ if (key) {
365
+ acc[key.trim()] = valueParts.join('='); // Handle values with = in them
366
+ }
367
+ return acc;
368
+ },
369
+ {} as Record<string, string>
370
+ );
371
+
372
+ return cookies[BETTER_AUTH_COOKIE_NAME] || null;
373
+ }
374
+
375
+ export default oauthWebRoutes;
@@ -0,0 +1,87 @@
1
+ import fastify from "fastify";
2
+
3
+ import config from "./plugins/config";
4
+ import auth from "./plugins/auth";
5
+ import errorHandler from "./plugins/error-handler";
6
+ import authRoutes from "./routes/auth";
7
+ import deviceSessionRoutes from "./routes/device-sessions";
8
+ <% if (platforms.includes('web')) { %>
9
+ import oauthWebRoutes from "./routes/oauth-web";
10
+ import { initRedis } from "../../utils/redis";
11
+ <% } %>
12
+
13
+ const server = fastify({
14
+ ajv: {
15
+ customOptions: {
16
+ removeAdditional: "all",
17
+ coerceTypes: true,
18
+ useDefaults: true,
19
+ formats: {
20
+ 'date-time': true, // Accept any string for date-time format
21
+ },
22
+ },
23
+ },
24
+ logger: {
25
+ level: process.env.LOG_LEVEL || 'info',
26
+ transport: process.env.NODE_ENV === 'development' ? {
27
+ target: 'pino-pretty',
28
+ options: {
29
+ translateTime: 'HH:MM:ss Z',
30
+ ignore: 'pid,hostname',
31
+ },
32
+ } : undefined,
33
+ },
34
+ // Generate request IDs for better error tracking
35
+ genReqId: () => `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
36
+ });
37
+
38
+ // Register core plugins first
39
+ await server.register(config);
40
+
41
+ // Register error handler early to catch all errors
42
+ await server.register(errorHandler);
43
+
44
+ // Register CORS with credentials support for cookie-based auth
45
+ await server.register(import('@fastify/cors'), {
46
+ // In production, use explicit origins; in development, allow all
47
+ origin: process.env.NODE_ENV === 'production'
48
+ ? (process.env.ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || [])
49
+ : true,
50
+ // Required for cookie-based auth - allows cookies to be sent cross-origin
51
+ credentials: true,
52
+ // Allowed headers for requests
53
+ allowedHeaders: ['Content-Type', 'Cookie', 'X-Device-Session-Token'],
54
+ // Expose Set-Cookie header so clients can receive cookies
55
+ exposedHeaders: ['set-cookie'],
56
+ });
57
+
58
+ // Register auth plugin
59
+ await server.register(auth);
60
+
61
+ <% if (platforms.includes('web')) { %>
62
+ // Initialize Redis for web OAuth PKCE flow
63
+ await initRedis();
64
+
65
+ <% } %>
66
+ // Register routes
67
+ // Auth routes at /api/auth to match BetterAuth basePath
68
+ await server.register(authRoutes, { prefix: "/api/auth" });
69
+ // Device session routes for anonymous sessions
70
+ await server.register(deviceSessionRoutes, { prefix: "/device-sessions" });
71
+ <% if (platforms.includes('web')) { %>
72
+ // Web OAuth routes for BFF pattern with PKCE
73
+ await server.register(oauthWebRoutes, { prefix: "/api/auth/web/oauth" });
74
+ <% } %>
75
+
76
+ // Root health check
77
+ server.get("/", async (request, reply) => {
78
+ return reply.code(200).send({
79
+ message: "API is running",
80
+ timestamp: new Date().toISOString(),
81
+ version: "1.0.0"
82
+ });
83
+ });
84
+
85
+ await server.ready();
86
+
87
+ export default server;
@@ -0,0 +1,209 @@
1
+ import { eq, lt } from 'drizzle-orm';
2
+ import { db, schema } from "../../utils/db";
3
+ import { ErrorFactory } from "../../utils/errors";
4
+ import {
5
+ DeviceSession,
6
+ CreateDeviceSessionBody,
7
+ CreateDeviceSessionResponse,
8
+ DeviceSessionMigrationEligibilityResponse,
9
+ } from "./schema";
10
+
11
+ const generateSessionToken = (): string => {
12
+ return crypto.randomUUID();
13
+ };
14
+
15
+ export const createDeviceSession = async (data: CreateDeviceSessionBody): Promise<CreateDeviceSessionResponse> => {
16
+ const { deviceId } = data;
17
+
18
+ try {
19
+ const sessionToken = generateSessionToken();
20
+
21
+ const [session] = await db
22
+ .insert(schema.deviceSession)
23
+ .values({
24
+ deviceId,
25
+ sessionToken,
26
+ preferredCurrency: 'USD',
27
+ migrated: false,
28
+ lastActiveAt: new Date(),
29
+ })
30
+ .returning();
31
+
32
+ return {
33
+ session: {
34
+ ...session,
35
+ createdAt: session.createdAt.toISOString(),
36
+ lastActiveAt: session.lastActiveAt.toISOString(),
37
+ },
38
+ sessionToken,
39
+ };
40
+ } catch (error) {
41
+ throw ErrorFactory.databaseError({
42
+ operation: 'createDeviceSession',
43
+ deviceId,
44
+ originalError: error instanceof Error ? error.message : String(error)
45
+ });
46
+ }
47
+ };
48
+
49
+ export const validateDeviceSession = async (sessionToken: string): Promise<DeviceSession | null> => {
50
+ try {
51
+ const [session] = await db
52
+ .select()
53
+ .from(schema.deviceSession)
54
+ .where(eq(schema.deviceSession.sessionToken, sessionToken))
55
+ .limit(1);
56
+
57
+ if (!session) {
58
+ return null;
59
+ }
60
+
61
+ // Check if session is expired (30 days of inactivity)
62
+ const thirtyDaysAgo = new Date();
63
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
64
+
65
+ if (session.lastActiveAt < thirtyDaysAgo) {
66
+ await deleteDeviceSession(sessionToken);
67
+ return null;
68
+ }
69
+
70
+ return {
71
+ ...session,
72
+ createdAt: session.createdAt.toISOString(),
73
+ lastActiveAt: session.lastActiveAt.toISOString(),
74
+ };
75
+ } catch (error) {
76
+ throw ErrorFactory.databaseError({
77
+ operation: 'validateDeviceSession',
78
+ sessionToken,
79
+ originalError: error instanceof Error ? error.message : String(error)
80
+ });
81
+ }
82
+ };
83
+
84
+ export const updateDeviceSessionActivity = async (sessionToken: string): Promise<void> => {
85
+ try {
86
+ await db
87
+ .update(schema.deviceSession)
88
+ .set({ lastActiveAt: new Date() })
89
+ .where(eq(schema.deviceSession.sessionToken, sessionToken));
90
+ } catch (error) {
91
+ throw ErrorFactory.databaseError({
92
+ operation: 'updateDeviceSessionActivity',
93
+ sessionToken,
94
+ originalError: error instanceof Error ? error.message : String(error)
95
+ });
96
+ }
97
+ };
98
+
99
+ export const deleteDeviceSession = async (sessionToken: string): Promise<void> => {
100
+ try {
101
+ await db
102
+ .delete(schema.deviceSession)
103
+ .where(eq(schema.deviceSession.sessionToken, sessionToken));
104
+ } catch (error) {
105
+ throw ErrorFactory.databaseError({
106
+ operation: 'deleteDeviceSession',
107
+ sessionToken,
108
+ originalError: error instanceof Error ? error.message : String(error)
109
+ });
110
+ }
111
+ };
112
+
113
+ export const validateDeviceSessionMigrationEligibility = async (sessionToken: string): Promise<DeviceSessionMigrationEligibilityResponse> => {
114
+ try {
115
+ const session = await validateDeviceSession(sessionToken);
116
+
117
+ if (!session) {
118
+ return {
119
+ canMigrate: false,
120
+ reason: 'Device session not found or expired',
121
+ };
122
+ }
123
+
124
+ if (session.migrated) {
125
+ return {
126
+ canMigrate: false,
127
+ reason: 'Device session already migrated to user account',
128
+ };
129
+ }
130
+
131
+ return {
132
+ canMigrate: true,
133
+ };
134
+ } catch (error) {
135
+ throw ErrorFactory.databaseError({
136
+ operation: 'validateDeviceSessionMigrationEligibility',
137
+ sessionToken,
138
+ originalError: error instanceof Error ? error.message : String(error)
139
+ });
140
+ }
141
+ };
142
+
143
+ export const migrateDeviceSessionToUser = async (sessionToken: string, userId: string): Promise<void> => {
144
+ try {
145
+ await db
146
+ .update(schema.deviceSession)
147
+ .set({
148
+ migrated: true,
149
+ migratedToUserId: userId,
150
+ })
151
+ .where(eq(schema.deviceSession.sessionToken, sessionToken));
152
+ } catch (error) {
153
+ throw ErrorFactory.databaseError({
154
+ operation: 'migrateDeviceSessionToUser',
155
+ sessionToken,
156
+ userId,
157
+ originalError: error instanceof Error ? error.message : String(error)
158
+ });
159
+ }
160
+ };
161
+
162
+ export const cleanupExpiredDeviceSessions = async (): Promise<number> => {
163
+ try {
164
+ const thirtyDaysAgo = new Date();
165
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
166
+
167
+ const result = await db
168
+ .delete(schema.deviceSession)
169
+ .where(lt(schema.deviceSession.lastActiveAt, thirtyDaysAgo))
170
+ .returning({ id: schema.deviceSession.id });
171
+
172
+ return result.length;
173
+ } catch (error) {
174
+ throw ErrorFactory.databaseError({
175
+ operation: 'cleanupExpiredDeviceSessions',
176
+ originalError: error instanceof Error ? error.message : String(error)
177
+ });
178
+ }
179
+ };
180
+
181
+ export const getDeviceSessionById = async (sessionId: string): Promise<DeviceSession | null> => {
182
+ try {
183
+ const [session] = await db
184
+ .select()
185
+ .from(schema.deviceSession)
186
+ .where(eq(schema.deviceSession.id, sessionId))
187
+ .limit(1);
188
+
189
+ if (!session) {
190
+ return null;
191
+ }
192
+
193
+ return {
194
+ ...session,
195
+ createdAt: session.createdAt.toISOString(),
196
+ lastActiveAt: session.lastActiveAt.toISOString(),
197
+ };
198
+ } catch (error) {
199
+ throw ErrorFactory.databaseError({
200
+ operation: 'getDeviceSessionById',
201
+ sessionId,
202
+ originalError: error instanceof Error ? error.message : String(error)
203
+ });
204
+ }
205
+ };
206
+
207
+ export const getDeviceSessionByToken = async (sessionToken: string): Promise<DeviceSession | null> => {
208
+ return validateDeviceSession(sessionToken);
209
+ };