@nakedev/go-scaffold 0.4.0 → 0.5.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 (192) hide show
  1. package/README.md +598 -306
  2. package/dist/commands/auth.js +65 -23
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/config.js +50 -0
  5. package/dist/commands/create.js +33 -2
  6. package/dist/commands/generate.js +29 -3
  7. package/dist/commands/method.js +74 -63
  8. package/dist/commands/migration.js +2 -2
  9. package/dist/commands/observability.js +4 -53
  10. package/dist/commands/rbac.js +21 -10
  11. package/dist/commands/undo.js +11 -3
  12. package/dist/commands/worker.js +15 -5
  13. package/dist/index.js +198 -59
  14. package/dist/prompts/auth-wizard.js +40 -6
  15. package/dist/prompts/create-wizard.js +42 -1
  16. package/dist/prompts/generate-wizard.js +89 -9
  17. package/dist/templates/auth-manifest.js +50 -19
  18. package/dist/templates/create-manifest.js +8 -0
  19. package/dist/templates/module-manifest.js +84 -26
  20. package/dist/templates/rbac-manifest.js +16 -11
  21. package/dist/templates/worker-manifest.js +4 -1
  22. package/dist/types.js +8 -0
  23. package/dist/utils/auth-patcher.js +124 -33
  24. package/dist/utils/config.js +167 -4
  25. package/dist/utils/docs-patcher.js +68 -0
  26. package/dist/utils/hexagonal-method-patcher.js +334 -0
  27. package/dist/utils/main-patcher.js +32 -30
  28. package/dist/utils/marker-patch.js +7 -1
  29. package/dist/utils/module-location.js +17 -11
  30. package/dist/utils/module-profile.js +32 -0
  31. package/dist/utils/platform-patcher.js +56 -7
  32. package/dist/utils/rbac-patcher.js +89 -210
  33. package/package.json +7 -2
  34. package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
  35. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  36. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  38. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  39. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  40. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  41. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  42. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  43. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  44. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  45. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  46. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  47. package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
  48. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  49. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
  50. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
  51. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
  52. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
  53. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
  54. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
  55. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
  56. package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
  57. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  58. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  59. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
  60. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
  61. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  62. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  63. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  64. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
  65. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
  66. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
  67. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
  68. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
  69. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  70. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  71. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  72. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
  73. package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
  74. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
  75. package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
  76. package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
  77. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  78. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
  79. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
  80. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
  81. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  82. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
  83. package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
  84. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  85. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
  86. package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
  87. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  88. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  89. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  90. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  91. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  92. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  93. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
  94. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  95. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  96. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  97. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  98. package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
  99. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  100. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  101. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  102. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  103. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  104. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  105. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  106. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  107. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  108. package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
  109. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  110. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  111. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  112. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  113. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  114. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  115. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
  116. package/templates/create/base/.env.example.hbs +0 -1
  117. package/templates/create/base/.golangci.yml.hbs +2 -2
  118. package/templates/create/base/AGENTS.md.hbs +279 -67
  119. package/templates/create/base/Makefile.hbs +2 -1
  120. package/templates/create/base/README.md.hbs +115 -32
  121. package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
  122. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  123. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  124. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  125. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  126. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  127. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  128. package/templates/create/features/docs/architecture.md.hbs +92 -32
  129. package/templates/create/features/docs/patterns.md.hbs +137 -91
  130. package/templates/create/features/docs/techstack.md.hbs +18 -3
  131. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  132. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  133. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  134. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  135. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  136. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  137. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  138. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  139. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  140. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  141. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  142. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  143. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  144. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  145. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  146. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  147. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  148. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  149. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  150. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  151. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  152. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  153. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  154. package/templates/generate/module/migration.up.sql.hbs +1 -1
  155. package/dist/utils/method-patcher.js +0 -357
  156. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  157. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
  158. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
  159. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
  160. package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
  161. package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
  162. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
  163. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
  164. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  165. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
  166. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
  167. package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
  168. package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
  169. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
  170. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
  171. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
  172. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  173. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  174. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  175. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  176. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  177. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  178. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  179. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  180. package/templates/generate/module/dto.go.hbs +0 -36
  181. package/templates/generate/module/errors.go.hbs +0 -33
  182. package/templates/generate/module/handler.go.hbs +0 -134
  183. package/templates/generate/module/handler_test.go.hbs +0 -174
  184. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  185. package/templates/generate/module/minimal/handler.go.hbs +0 -48
  186. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  187. package/templates/generate/module/minimal/service.go.hbs +0 -45
  188. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  189. package/templates/generate/module/model/model.go.hbs +0 -36
  190. package/templates/generate/module/repository.go.hbs +0 -103
  191. package/templates/generate/module/service.go.hbs +0 -108
  192. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -0,0 +1,228 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "errors"
5
+ "net/http"
6
+ "time"
7
+
8
+ "{{goModule}}/internal/app/user/application"
9
+ "{{goModule}}/internal/app/user/domain"
10
+ "{{goModule}}/internal/shared/apperror"
11
+ "{{goModule}}/internal/shared/httpx"
12
+ "{{goModule}}/internal/shared/middleware"
13
+
14
+ "{{goModule}}/internal/shared/pagination"
15
+ "github.com/gin-gonic/gin"
16
+ // go-scaffold:user-handler-imports
17
+ )
18
+
19
+ const (
20
+ refreshCookieName = "refresh_token"
21
+ )
22
+
23
+ const PermUserManageRole = "user:manage-role"
24
+ const PermUserRead = "user:read"
25
+
26
+ // go-scaffold:user-handler-consts
27
+
28
+ // authorizer is the optional capability RBAC supplies to protect admin
29
+ // routes. Auth itself does not import the RBAC middleware implementation.
30
+ type authorizer interface {
31
+ Require(string) gin.HandlerFunc
32
+ }
33
+
34
+ type Handler struct {
35
+ svc application.ServicePort
36
+ jwtSecret string
37
+ refreshTTL time.Duration
38
+ cookieSecure bool
39
+ cookieSameSite string
40
+ allowedOrigins []string
41
+ // limiter, not a *redis.Client: which backing store counts the requests is
42
+ // decided by `add auth --store`, and this file must not care.
43
+ limiter middleware.Limiter
44
+ authz authorizer
45
+ // go-scaffold:user-handler-fields
46
+ }
47
+
48
+ func NewHandler(
49
+ svc application.ServicePort,
50
+ jwtSecret string,
51
+ refreshTTL time.Duration,
52
+ cookieSecure bool,
53
+ cookieSameSite string,
54
+ limiter middleware.Limiter,
55
+ authz ...authorizer,
56
+ // go-scaffold:user-handler-params
57
+ ) *Handler {
58
+ return newHandler(svc, jwtSecret, refreshTTL, cookieSecure, cookieSameSite, nil, limiter, authz...)
59
+ }
60
+
61
+ // NewHandlerWithOrigins is the composition entry point when the application
62
+ // uses cookies across browser origins.
63
+ func NewHandlerWithOrigins(
64
+ svc application.ServicePort,
65
+ jwtSecret string,
66
+ refreshTTL time.Duration,
67
+ cookieSecure bool,
68
+ cookieSameSite string,
69
+ allowedOrigins []string,
70
+ limiter middleware.Limiter,
71
+ authz ...authorizer,
72
+ // go-scaffold:user-handler-with-origins-params
73
+ ) *Handler {
74
+ return newHandler(svc, jwtSecret, refreshTTL, cookieSecure, cookieSameSite, allowedOrigins, limiter, authz...)
75
+ }
76
+
77
+ func newHandler(
78
+ svc application.ServicePort,
79
+ jwtSecret string,
80
+ refreshTTL time.Duration,
81
+ cookieSecure bool,
82
+ cookieSameSite string,
83
+ allowedOrigins []string,
84
+ limiter middleware.Limiter,
85
+ authz ...authorizer,
86
+ ) *Handler {
87
+ var authzMiddleware authorizer
88
+ if len(authz) > 0 {
89
+ authzMiddleware = authz[0]
90
+ }
91
+ return &Handler{
92
+ svc: svc,
93
+ jwtSecret: jwtSecret,
94
+ refreshTTL: refreshTTL,
95
+ cookieSecure: cookieSecure,
96
+ cookieSameSite: cookieSameSite,
97
+ allowedOrigins: append([]string(nil), allowedOrigins...),
98
+ limiter: limiter,
99
+ authz: authzMiddleware,
100
+ // go-scaffold:user-handler-init
101
+ }
102
+ }
103
+
104
+ // Register owns the public/protected route split for the user feature. The
105
+ // endpoint implementations live in focused handler files; this file remains
106
+ // the single route/composition surface that generator patches can target.
107
+ func (h *Handler) Register(rg gin.IRouter) {
108
+ loginLimit := middleware.RateLimit(h.limiter, "login", 10, time.Minute)
109
+ registerLimit := middleware.RateLimit(h.limiter, "register", 5, time.Minute)
110
+ forgotPasswordLimit := middleware.RateLimit(h.limiter, "forgot-password", 5, time.Minute)
111
+ resetPasswordLimit := middleware.RateLimit(h.limiter, "reset-password", 10, time.Minute)
112
+ verifyEmailLimit := middleware.RateLimit(h.limiter, "verify-email", 10, time.Minute)
113
+ resendVerificationLimit := middleware.RateLimit(h.limiter, "resend-verification", 5, time.Minute)
114
+ mfaVerifyLimit := middleware.RateLimit(h.limiter, "mfa-verify", 5, time.Minute)
115
+ mfaSetupLimit := middleware.RateLimit(h.limiter, "mfa-setup", 5, time.Minute)
116
+ mfaConfirmLimit := middleware.RateLimit(h.limiter, "mfa-confirm", 5, time.Minute)
117
+ mfaDisableLimit := middleware.RateLimit(h.limiter, "mfa-disable", 5, time.Minute)
118
+
119
+ authGroup := rg.Group("/auth")
120
+ authGroup.POST("/register", registerLimit, h.register)
121
+ authGroup.POST("/login", loginLimit, h.login)
122
+ authGroup.POST("/refresh", h.refresh)
123
+ authGroup.POST("/logout", h.logout)
124
+ authGroup.POST("/forgot-password", forgotPasswordLimit, h.forgotPassword)
125
+ authGroup.POST("/reset-password", resetPasswordLimit, h.resetPassword)
126
+ authGroup.POST("/verify-email", verifyEmailLimit, h.verifyEmail)
127
+ authGroup.POST("/mfa/verify", mfaVerifyLimit, h.verifyMFA)
128
+ authGroup.GET("/:provider/login", h.providerLogin)
129
+ authGroup.POST("/:provider/exchange", h.providerExchange)
130
+
131
+ usersGroup := rg.Group("/users", middleware.RequireAuth(h.jwtSecret))
132
+ usersGroup.GET("/me", h.me)
133
+ usersGroup.POST("/me/resend-verification", resendVerificationLimit, h.resendVerification)
134
+ usersGroup.POST("/me/logout-all", h.logoutAll)
135
+ usersGroup.GET("/me/mfa", h.mfaStatus)
136
+ usersGroup.POST("/me/mfa/setup", mfaSetupLimit, h.setupMFA)
137
+ usersGroup.POST("/me/mfa/confirm", mfaConfirmLimit, h.confirmMFA)
138
+ usersGroup.POST("/me/mfa/disable", mfaDisableLimit, h.disableMFA)
139
+ // Admin user routes are enabled only when the optional RBAC authorizer is
140
+ // supplied by the composition root. Auth-only projects keep the endpoint
141
+ // surface limited to authentication and the current-user operations.
142
+ if h.authz != nil {
143
+ usersGroup.GET("", h.authz.Require(PermUserRead), h.adminListUsers)
144
+ usersGroup.GET("/:id", h.authz.Require(PermUserRead), h.adminGetUser)
145
+ usersGroup.PATCH("/:id/set-role", h.authz.Require(PermUserManageRole), h.setRole)
146
+ }
147
+ // go-scaffold:handler-routes
148
+ // go-scaffold:user-routes
149
+ }
150
+
151
+ func (h *Handler) adminListUsers(c *gin.Context) {
152
+ p := pagination.Parse(c)
153
+ items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
154
+ if err != nil {
155
+ c.Error(toHTTPError(err))
156
+ return
157
+ }
158
+ out := make([]meResponse, len(items))
159
+ for i := range items {
160
+ out[i] = toMeResponse(&items[i])
161
+ }
162
+ c.JSON(http.StatusOK, p.Response(out))
163
+ }
164
+ func (h *Handler) adminGetUser(c *gin.Context) {
165
+ id, ok := httpx.ParseID(c)
166
+ if !ok {
167
+ return
168
+ }
169
+ u, err := h.svc.Get(c.Request.Context(), id)
170
+ if err != nil {
171
+ c.Error(toHTTPError(err))
172
+ return
173
+ }
174
+ c.JSON(http.StatusOK, toMeResponse(u))
175
+ }
176
+ func (h *Handler) setRole(c *gin.Context) {
177
+ id, ok := httpx.ParseID(c)
178
+ if !ok {
179
+ return
180
+ }
181
+ var in setRoleInput
182
+ if err := c.ShouldBindJSON(&in); err != nil {
183
+ c.Error(httpx.BindErr(err))
184
+ return
185
+ }
186
+ u, err := h.svc.SetRole(c.Request.Context(), id, in.Role)
187
+ if err != nil {
188
+ c.Error(toHTTPError(err))
189
+ return
190
+ }
191
+ c.JSON(http.StatusOK, toMeResponse(u))
192
+ }
193
+
194
+ func toHTTPError(err error) error {
195
+ if err == nil {
196
+ return nil
197
+ }
198
+ var appErr *apperror.AppError
199
+ if errors.As(err, &appErr) {
200
+ return err
201
+ }
202
+ var ruleErr *domain.RuleError
203
+ if errors.As(err, &ruleErr) {
204
+ status := http.StatusInternalServerError
205
+ switch ruleErr.Code {
206
+ case "USER_NOT_FOUND":
207
+ status = http.StatusNotFound
208
+ case "USER_EMAIL_TAKEN", "AUTH_ALREADY_VERIFIED", "AUTH_MFA_ALREADY_ENABLED", "AUTH_MFA_NOT_ENROLLED", "AUTH_MFA_SETUP_REQUIRED":
209
+ status = http.StatusConflict
210
+ case "AUTH_INVALID_CREDENTIALS", "AUTH_INVALID_TOKEN", "AUTH_MFA_INVALID":
211
+ status = http.StatusUnauthorized
212
+ case "AUTH_MFA_UNAVAILABLE":
213
+ status = http.StatusServiceUnavailable
214
+ case "AUTH_TOO_MANY_ATTEMPTS":
215
+ status = http.StatusTooManyRequests
216
+ case "USER_UNKNOWN_ROLE":
217
+ status = http.StatusUnprocessableEntity
218
+ }
219
+ return apperror.New(status, ruleErr.Code, ruleErr.Message)
220
+ }
221
+ if errors.Is(err, domain.ErrNotFound) {
222
+ return apperror.NewNotFound("user not found")
223
+ }
224
+ return apperror.NewInternal(err)
225
+ }
226
+
227
+ // go-scaffold:handler-funcs
228
+ // go-scaffold:user-handler-funcs
@@ -0,0 +1,76 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "{{goModule}}/internal/app/user/domain"
7
+ "{{goModule}}/internal/shared/httpx"
8
+
9
+ "github.com/gin-gonic/gin"
10
+ )
11
+
12
+ func (h *Handler) register(c *gin.Context) {
13
+ setNoStoreHeaders(c)
14
+ if !h.requireBrowserOrigin(c) {
15
+ return
16
+ }
17
+ var in registerInput
18
+ if err := c.ShouldBindJSON(&in); err != nil {
19
+ c.Error(httpx.BindErr(err))
20
+ return
21
+ }
22
+ auth, err := h.svc.Register(c.Request.Context(), toRegisterInput(in))
23
+ if err != nil {
24
+ c.Error(toHTTPError(err))
25
+ return
26
+ }
27
+ h.writeAuthResult(c, http.StatusCreated, auth)
28
+ }
29
+
30
+ func (h *Handler) login(c *gin.Context) {
31
+ setNoStoreHeaders(c)
32
+ if !h.requireBrowserOrigin(c) {
33
+ return
34
+ }
35
+ var in loginInput
36
+ if err := c.ShouldBindJSON(&in); err != nil {
37
+ c.Error(httpx.BindErr(err))
38
+ return
39
+ }
40
+ auth, err := h.svc.Login(c.Request.Context(), toLoginInput(in))
41
+ if err != nil {
42
+ c.Error(toHTTPError(err))
43
+ return
44
+ }
45
+ h.writeAuthResult(c, http.StatusOK, auth)
46
+ }
47
+
48
+ func (h *Handler) refresh(c *gin.Context) {
49
+ setNoStoreHeaders(c)
50
+ if !h.requireBrowserOrigin(c) {
51
+ return
52
+ }
53
+ raw, err := c.Cookie(refreshCookieName)
54
+ if err != nil || raw == "" {
55
+ c.Error(toHTTPError(domain.Rule("AUTH_INVALID_TOKEN", "invalid or expired token", domain.ErrInvalidToken)))
56
+ return
57
+ }
58
+ auth, err := h.svc.Refresh(c.Request.Context(), raw)
59
+ if err != nil {
60
+ h.clearRefreshCookie(c)
61
+ c.Error(toHTTPError(err))
62
+ return
63
+ }
64
+ h.setRefreshCookie(c, auth.RefreshToken)
65
+ c.JSON(http.StatusOK, toCookieResponse(auth))
66
+ }
67
+
68
+ func (h *Handler) logout(c *gin.Context) {
69
+ if !h.requireBrowserOrigin(c) {
70
+ return
71
+ }
72
+ raw, _ := c.Cookie(refreshCookieName)
73
+ _ = h.svc.Logout(c.Request.Context(), raw)
74
+ h.clearRefreshCookie(c)
75
+ c.Status(http.StatusNoContent)
76
+ }
@@ -0,0 +1,83 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "{{goModule}}/internal/shared/httpx"
7
+ "{{goModule}}/internal/shared/middleware"
8
+
9
+ "github.com/gin-gonic/gin"
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ func (h *Handler) verifyMFA(c *gin.Context) {
14
+ setNoStoreHeaders(c)
15
+ if !h.requireBrowserOrigin(c) {
16
+ return
17
+ }
18
+ var in mfaChallengeInput
19
+ if err := c.ShouldBindJSON(&in); err != nil {
20
+ c.Error(httpx.BindErr(err))
21
+ return
22
+ }
23
+ auth, err := h.svc.VerifyMFA(c.Request.Context(), in.Challenge, in.Code)
24
+ if err != nil {
25
+ c.Error(toHTTPError(err))
26
+ return
27
+ }
28
+ h.setRefreshCookie(c, auth.RefreshToken)
29
+ c.JSON(http.StatusOK, toCookieResponse(auth))
30
+ }
31
+
32
+ func (h *Handler) mfaStatus(c *gin.Context) {
33
+ setNoStoreHeaders(c)
34
+ status, err := h.svc.MFAStatus(c.Request.Context(), currentUserID(c))
35
+ if err != nil {
36
+ c.Error(toHTTPError(err))
37
+ return
38
+ }
39
+ c.JSON(http.StatusOK, mfaStatusResponse(status))
40
+ }
41
+
42
+ func (h *Handler) setupMFA(c *gin.Context) {
43
+ setNoStoreHeaders(c)
44
+ setup, err := h.svc.SetupMFA(c.Request.Context(), currentUserID(c))
45
+ if err != nil {
46
+ c.Error(toHTTPError(err))
47
+ return
48
+ }
49
+ c.JSON(http.StatusOK, mfaSetupResponse{Secret: setup.Secret, OTPAuthURI: setup.OTPAuthURI})
50
+ }
51
+
52
+ func (h *Handler) confirmMFA(c *gin.Context) {
53
+ setNoStoreHeaders(c)
54
+ var in mfaCodeInput
55
+ if err := c.ShouldBindJSON(&in); err != nil {
56
+ c.Error(httpx.BindErr(err))
57
+ return
58
+ }
59
+ codes, err := h.svc.ConfirmMFA(c.Request.Context(), currentUserID(c), in.Code)
60
+ if err != nil {
61
+ c.Error(toHTTPError(err))
62
+ return
63
+ }
64
+ c.JSON(http.StatusOK, mfaConfirmResponse{RecoveryCodes: codes})
65
+ }
66
+
67
+ func (h *Handler) disableMFA(c *gin.Context) {
68
+ setNoStoreHeaders(c)
69
+ var in mfaCodeInput
70
+ if err := c.ShouldBindJSON(&in); err != nil {
71
+ c.Error(httpx.BindErr(err))
72
+ return
73
+ }
74
+ if err := h.svc.DisableMFA(c.Request.Context(), currentUserID(c), in.Code); err != nil {
75
+ c.Error(toHTTPError(err))
76
+ return
77
+ }
78
+ c.Status(http.StatusNoContent)
79
+ }
80
+
81
+ func currentUserID(c *gin.Context) uuid.UUID {
82
+ return c.MustGet(middleware.UserIDKey).(uuid.UUID)
83
+ }
@@ -0,0 +1,70 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "errors"
5
+ "log/slog"
6
+ "net/http"
7
+
8
+ userapp "{{goModule}}/internal/app/user/application"
9
+ "{{goModule}}/internal/shared/apperror"
10
+
11
+ "github.com/gin-gonic/gin"
12
+ )
13
+
14
+ func (h *Handler) providerLogin(c *gin.Context) {
15
+ start, err := h.svc.BeginLogin(c.Request.Context(), c.Param("provider"), toLoginStartInput(loginStartInput{
16
+ State: c.Query("state"),
17
+ CodeChallenge: c.Query("code_challenge"),
18
+ CodeChallengeMethod: c.Query("code_challenge_method"),
19
+ }))
20
+ if err != nil {
21
+ c.Error(h.oauthAppError(c.Param("provider"), err))
22
+ return
23
+ }
24
+ c.Redirect(http.StatusFound, start.URL)
25
+ }
26
+
27
+ func (h *Handler) providerExchange(c *gin.Context) {
28
+ setNoStoreHeaders(c)
29
+ if !h.requireBrowserOrigin(c) {
30
+ return
31
+ }
32
+ var in loginExchangeInput
33
+ if err := c.ShouldBindJSON(&in); err != nil {
34
+ c.Error(h.oauthAppError(c.Param("provider"), userapp.NewOAuthError(userapp.OAuthFailed, err)))
35
+ return
36
+ }
37
+ auth, err := h.svc.ExchangeLogin(c.Request.Context(), c.Param("provider"), toLoginExchangeInput(in))
38
+ if err != nil {
39
+ c.Error(h.oauthAppError(c.Param("provider"), err))
40
+ return
41
+ }
42
+ h.writeAuthResult(c, http.StatusOK, auth)
43
+ }
44
+
45
+ func (h *Handler) oauthAppError(provider string, err error) *apperror.AppError {
46
+ code := userapp.OAuthFailed
47
+ status := http.StatusBadRequest
48
+ message := "external login failed"
49
+ var oauthErr *userapp.OAuthError
50
+ if errors.As(err, &oauthErr) {
51
+ switch oauthErr.Code {
52
+ case userapp.OAuthDenied:
53
+ code = userapp.OAuthDenied
54
+ message = "external login was denied"
55
+ case userapp.OAuthStateInvalid:
56
+ code = userapp.OAuthStateInvalid
57
+ message = "oauth state or PKCE verifier is invalid"
58
+ case userapp.OAuthProviderUnavailable:
59
+ code = userapp.OAuthProviderUnavailable
60
+ status = http.StatusServiceUnavailable
61
+ message = "external login provider is unavailable"
62
+ case userapp.OAuthFailed:
63
+ code = userapp.OAuthFailed
64
+ default:
65
+ code = userapp.OAuthFailed
66
+ }
67
+ }
68
+ slog.Warn("oauth request failed", "provider", provider, "code", code, "error", err)
69
+ return apperror.New(status, string(code), message)
70
+ }
@@ -0,0 +1,49 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "{{goModule}}/internal/shared/httpx"
7
+
8
+ "github.com/gin-gonic/gin"
9
+ )
10
+
11
+ func (h *Handler) forgotPassword(c *gin.Context) {
12
+ var in forgotPasswordInput
13
+ if err := c.ShouldBindJSON(&in); err != nil {
14
+ c.Error(httpx.BindErr(err))
15
+ return
16
+ }
17
+ if err := h.svc.ForgotPassword(c.Request.Context(), in.Email); err != nil {
18
+ c.Error(toHTTPError(err))
19
+ return
20
+ }
21
+ // Always the same response, whether or not the email exists.
22
+ c.JSON(http.StatusOK, gin.H{"message": "if that email exists, a reset link has been sent"})
23
+ }
24
+
25
+ func (h *Handler) resetPassword(c *gin.Context) {
26
+ var in resetPasswordInput
27
+ if err := c.ShouldBindJSON(&in); err != nil {
28
+ c.Error(httpx.BindErr(err))
29
+ return
30
+ }
31
+ if err := h.svc.ResetPassword(c.Request.Context(), in.Token, in.NewPassword); err != nil {
32
+ c.Error(toHTTPError(err))
33
+ return
34
+ }
35
+ c.Status(http.StatusNoContent)
36
+ }
37
+
38
+ func (h *Handler) verifyEmail(c *gin.Context) {
39
+ var in verifyEmailInput
40
+ if err := c.ShouldBindJSON(&in); err != nil {
41
+ c.Error(httpx.BindErr(err))
42
+ return
43
+ }
44
+ if err := h.svc.VerifyEmail(c.Request.Context(), in.Token); err != nil {
45
+ c.Error(toHTTPError(err))
46
+ return
47
+ }
48
+ c.Status(http.StatusNoContent)
49
+ }