@nakedev/go-scaffold 0.4.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +567 -513
  2. package/dist/commands/auth.js +12 -1
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/create.js +2 -1
  5. package/dist/commands/generate.js +5 -2
  6. package/dist/commands/method.js +66 -70
  7. package/dist/commands/observability.js +4 -53
  8. package/dist/commands/rbac.js +19 -8
  9. package/dist/commands/undo.js +6 -3
  10. package/dist/commands/worker.js +14 -4
  11. package/dist/index.js +13 -1
  12. package/dist/templates/auth-manifest.js +46 -45
  13. package/dist/templates/create-manifest.js +4 -0
  14. package/dist/templates/module-manifest.js +82 -60
  15. package/dist/templates/rbac-manifest.js +15 -11
  16. package/dist/templates/worker-manifest.js +4 -1
  17. package/dist/types.js +2 -0
  18. package/dist/utils/auth-patcher.js +22 -22
  19. package/dist/utils/config.js +24 -5
  20. package/dist/utils/docs-patcher.js +68 -0
  21. package/dist/utils/hexagonal-method-patcher.js +334 -0
  22. package/dist/utils/main-patcher.js +3 -3
  23. package/dist/utils/module-location.js +17 -11
  24. package/dist/utils/platform-patcher.js +27 -0
  25. package/dist/utils/rbac-patcher.js +73 -216
  26. package/package.json +1 -1
  27. package/templates/add/auth/cmd/seed/main.go.hbs +2 -0
  28. package/templates/add/auth/internal/app/user/{browser_policy.go.hbs → adapters/inbound/http/browser_policy.go.hbs} +3 -3
  29. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  30. package/templates/add/auth/internal/app/user/{handler.go.hbs → adapters/inbound/http/handler.go.hbs} +103 -8
  31. package/templates/add/auth/internal/app/user/{handler_local.go.hbs → adapters/inbound/http/handler_local.go.hbs} +8 -7
  32. package/templates/add/auth/internal/app/user/{handler_mfa.go.hbs → adapters/inbound/http/handler_mfa.go.hbs} +6 -6
  33. package/templates/add/auth/internal/app/user/{handler_oauth.go.hbs → adapters/inbound/http/handler_oauth.go.hbs} +17 -17
  34. package/templates/add/auth/internal/app/user/{handler_recovery.go.hbs → adapters/inbound/http/handler_recovery.go.hbs} +4 -4
  35. package/templates/add/auth/internal/app/user/{handler_test.go.hbs → adapters/inbound/http/handler_test.go.hbs} +52 -31
  36. package/templates/add/auth/internal/app/user/{handler_user.go.hbs → adapters/inbound/http/handler_user.go.hbs} +4 -4
  37. package/templates/add/auth/internal/app/user/{session_cookie.go.hbs → adapters/inbound/http/session_cookie.go.hbs} +5 -3
  38. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  39. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  40. package/templates/add/auth/internal/app/user/{mfa_store.go.hbs → adapters/outbound/postgres/mfa_store.go.hbs} +22 -20
  41. package/templates/add/auth/internal/app/user/{mfa_store_test.go.hbs → adapters/outbound/postgres/mfa_store_test.go.hbs} +7 -7
  42. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  43. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  44. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  45. package/templates/add/auth/internal/app/user/{tokenstore_pg.go.hbs → adapters/outbound/postgres/tokenstore_pg.go.hbs} +36 -32
  46. package/templates/add/auth/internal/app/user/{tokenstore_pg_test.go.hbs → adapters/outbound/postgres/tokenstore_pg_test.go.hbs} +10 -3
  47. package/templates/add/auth/internal/app/user/{tokenstore_recovery.go.hbs → adapters/outbound/postgres/tokenstore_recovery.go.hbs} +29 -3
  48. package/templates/add/auth/internal/app/user/{tokenstore_redis.go.hbs → adapters/outbound/redis/tokenstore.go.hbs} +43 -36
  49. package/templates/add/auth/internal/app/user/{tokenstore_redis_test.go.hbs → adapters/outbound/redis/tokenstore_test.go.hbs} +14 -3
  50. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  51. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  52. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  53. package/templates/add/auth/internal/app/user/{external_login.go.hbs → application/external_login.go.hbs} +30 -40
  54. package/templates/add/auth/internal/app/user/{jwt.go.hbs → application/jwt.go.hbs} +6 -3
  55. package/templates/add/auth/internal/app/user/{local_auth.go.hbs → application/local_auth.go.hbs} +17 -19
  56. package/templates/add/auth/internal/app/user/{mfa_service.go.hbs → application/mfa_service.go.hbs} +27 -28
  57. package/templates/add/auth/internal/app/user/{mfa_service_test.go.hbs → application/mfa_service_test.go.hbs} +12 -11
  58. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +3 -3
  59. package/templates/add/auth/internal/app/user/{provider_test.go.hbs → application/provider_test.go.hbs} +55 -56
  60. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +16 -47
  61. package/templates/add/auth/internal/app/user/{recovery_service.go.hbs → application/recovery_service.go.hbs} +13 -15
  62. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  63. package/templates/add/auth/internal/app/user/{service_test.go.hbs → application/service_test.go.hbs} +94 -75
  64. package/templates/add/auth/internal/app/user/{sessions.go.hbs → application/sessions.go.hbs} +14 -14
  65. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  66. package/templates/add/auth/internal/app/user/{user_query.go.hbs → application/user_query.go.hbs} +16 -16
  67. package/templates/add/auth/internal/app/user/composition.go.hbs +89 -86
  68. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  69. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  70. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  71. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  72. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +1 -1
  73. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  74. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -1
  75. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  76. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  77. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  78. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  79. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  80. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  81. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  82. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  83. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  84. package/templates/add/rbac/internal/app/role/composition.go.hbs +23 -10
  85. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  86. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  87. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  88. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  89. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  90. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  91. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +54 -36
  92. package/templates/create/base/.golangci.yml.hbs +2 -2
  93. package/templates/create/base/AGENTS.md.hbs +55 -31
  94. package/templates/create/base/README.md.hbs +77 -22
  95. package/templates/create/base/cmd/api/wiring.go.hbs +3 -2
  96. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  97. package/templates/create/features/docs/architecture.md.hbs +64 -26
  98. package/templates/create/features/docs/patterns.md.hbs +117 -90
  99. package/templates/create/features/docs/techstack.md.hbs +17 -2
  100. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  101. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  102. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  103. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  104. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  105. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  106. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  107. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  108. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  109. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  110. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  111. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  112. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  113. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  114. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  115. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  116. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  117. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  118. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  119. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  120. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  121. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  122. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  123. package/templates/generate/module/migration.up.sql.hbs +1 -1
  124. package/dist/utils/method-patcher.js +0 -537
  125. package/templates/add/auth/internal/app/user/contracts.go.hbs +0 -88
  126. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -134
  127. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -68
  128. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -45
  129. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -32
  130. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  131. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +0 -17
  132. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +0 -20
  133. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +0 -17
  134. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -31
  135. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -139
  136. package/templates/add/auth/internal/app/user/service.go.hbs +0 -135
  137. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -58
  138. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  139. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  140. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  141. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  142. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  143. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  144. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  145. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  146. package/templates/generate/module/commands.go.hbs +0 -95
  147. package/templates/generate/module/composition.go.hbs +0 -23
  148. package/templates/generate/module/cqrs_test.go.hbs +0 -7
  149. package/templates/generate/module/dto.go.hbs +0 -36
  150. package/templates/generate/module/errors.go.hbs +0 -33
  151. package/templates/generate/module/handler.go.hbs +0 -179
  152. package/templates/generate/module/handler_test.go.hbs +0 -174
  153. package/templates/generate/module/minimal/commands.go.hbs +0 -34
  154. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  155. package/templates/generate/module/minimal/handler.go.hbs +0 -82
  156. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  157. package/templates/generate/module/minimal/queries.go.hbs +0 -45
  158. package/templates/generate/module/minimal/service.go.hbs +0 -71
  159. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  160. package/templates/generate/module/model/model.go.hbs +0 -36
  161. package/templates/generate/module/queries.go.hbs +0 -62
  162. package/templates/generate/module/repository.go.hbs +0 -103
  163. package/templates/generate/module/service.go.hbs +0 -164
  164. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -1,10 +1,17 @@
1
- package user
1
+ package httpadapter
2
2
 
3
3
  import (
4
+ "errors"
5
+ "net/http"
4
6
  "time"
5
7
 
8
+ "{{goModule}}/internal/app/user/application"
9
+ "{{goModule}}/internal/app/user/domain"
10
+ "{{goModule}}/internal/shared/apperror"
11
+ "{{goModule}}/internal/shared/httpx"
6
12
  "{{goModule}}/internal/shared/middleware"
7
13
 
14
+ "{{goModule}}/internal/shared/pagination"
8
15
  "github.com/gin-gonic/gin"
9
16
  // go-scaffold:user-handler-imports
10
17
  )
@@ -13,6 +20,9 @@ const (
13
20
  refreshCookieName = "refresh_token"
14
21
  )
15
22
 
23
+ const PermUserManageRole = "user:manage-role"
24
+ const PermUserRead = "user:read"
25
+
16
26
  // go-scaffold:user-handler-consts
17
27
 
18
28
  // authorizer is the optional capability RBAC supplies to protect admin
@@ -22,7 +32,7 @@ type authorizer interface {
22
32
  }
23
33
 
24
34
  type Handler struct {
25
- svc *Service
35
+ svc application.ServicePort
26
36
  jwtSecret string
27
37
  refreshTTL time.Duration
28
38
  cookieSecure bool
@@ -36,7 +46,7 @@ type Handler struct {
36
46
  }
37
47
 
38
48
  func NewHandler(
39
- svc *Service,
49
+ svc application.ServicePort,
40
50
  jwtSecret string,
41
51
  refreshTTL time.Duration,
42
52
  cookieSecure bool,
@@ -48,11 +58,10 @@ func NewHandler(
48
58
  return newHandler(svc, jwtSecret, refreshTTL, cookieSecure, cookieSameSite, nil, limiter, authz...)
49
59
  }
50
60
 
51
- // NewHandlerWithOrigins is the current composition entry point. Keeping the
52
- // original NewHandler signature preserves source compatibility for projects
53
- // generated before the cross-site Origin guard was added.
61
+ // NewHandlerWithOrigins is the composition entry point when the application
62
+ // uses cookies across browser origins.
54
63
  func NewHandlerWithOrigins(
55
- svc *Service,
64
+ svc application.ServicePort,
56
65
  jwtSecret string,
57
66
  refreshTTL time.Duration,
58
67
  cookieSecure bool,
@@ -66,7 +75,7 @@ func NewHandlerWithOrigins(
66
75
  }
67
76
 
68
77
  func newHandler(
69
- svc *Service,
78
+ svc application.ServicePort,
70
79
  jwtSecret string,
71
80
  refreshTTL time.Duration,
72
81
  cookieSecure bool,
@@ -127,7 +136,93 @@ func (h *Handler) Register(rg gin.IRouter) {
127
136
  usersGroup.POST("/me/mfa/setup", mfaSetupLimit, h.setupMFA)
128
137
  usersGroup.POST("/me/mfa/confirm", mfaConfirmLimit, h.confirmMFA)
129
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
130
148
  // go-scaffold:user-routes
131
149
  }
132
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
133
228
  // go-scaffold:user-handler-funcs
@@ -1,8 +1,9 @@
1
- package user
1
+ package httpadapter
2
2
 
3
3
  import (
4
4
  "net/http"
5
5
 
6
+ "{{goModule}}/internal/app/user/domain"
6
7
  "{{goModule}}/internal/shared/httpx"
7
8
 
8
9
  "github.com/gin-gonic/gin"
@@ -18,9 +19,9 @@ func (h *Handler) register(c *gin.Context) {
18
19
  c.Error(httpx.BindErr(err))
19
20
  return
20
21
  }
21
- auth, err := h.svc.Register(c.Request.Context(), in)
22
+ auth, err := h.svc.Register(c.Request.Context(), toRegisterInput(in))
22
23
  if err != nil {
23
- c.Error(err)
24
+ c.Error(toHTTPError(err))
24
25
  return
25
26
  }
26
27
  h.writeAuthResult(c, http.StatusCreated, auth)
@@ -36,9 +37,9 @@ func (h *Handler) login(c *gin.Context) {
36
37
  c.Error(httpx.BindErr(err))
37
38
  return
38
39
  }
39
- auth, err := h.svc.Login(c.Request.Context(), in)
40
+ auth, err := h.svc.Login(c.Request.Context(), toLoginInput(in))
40
41
  if err != nil {
41
- c.Error(err)
42
+ c.Error(toHTTPError(err))
42
43
  return
43
44
  }
44
45
  h.writeAuthResult(c, http.StatusOK, auth)
@@ -51,13 +52,13 @@ func (h *Handler) refresh(c *gin.Context) {
51
52
  }
52
53
  raw, err := c.Cookie(refreshCookieName)
53
54
  if err != nil || raw == "" {
54
- c.Error(errInvalidToken())
55
+ c.Error(toHTTPError(domain.Rule("AUTH_INVALID_TOKEN", "invalid or expired token", domain.ErrInvalidToken)))
55
56
  return
56
57
  }
57
58
  auth, err := h.svc.Refresh(c.Request.Context(), raw)
58
59
  if err != nil {
59
60
  h.clearRefreshCookie(c)
60
- c.Error(err)
61
+ c.Error(toHTTPError(err))
61
62
  return
62
63
  }
63
64
  h.setRefreshCookie(c, auth.RefreshToken)
@@ -1,4 +1,4 @@
1
- package user
1
+ package httpadapter
2
2
 
3
3
  import (
4
4
  "net/http"
@@ -22,7 +22,7 @@ func (h *Handler) verifyMFA(c *gin.Context) {
22
22
  }
23
23
  auth, err := h.svc.VerifyMFA(c.Request.Context(), in.Challenge, in.Code)
24
24
  if err != nil {
25
- c.Error(err)
25
+ c.Error(toHTTPError(err))
26
26
  return
27
27
  }
28
28
  h.setRefreshCookie(c, auth.RefreshToken)
@@ -33,7 +33,7 @@ func (h *Handler) mfaStatus(c *gin.Context) {
33
33
  setNoStoreHeaders(c)
34
34
  status, err := h.svc.MFAStatus(c.Request.Context(), currentUserID(c))
35
35
  if err != nil {
36
- c.Error(err)
36
+ c.Error(toHTTPError(err))
37
37
  return
38
38
  }
39
39
  c.JSON(http.StatusOK, mfaStatusResponse(status))
@@ -43,7 +43,7 @@ func (h *Handler) setupMFA(c *gin.Context) {
43
43
  setNoStoreHeaders(c)
44
44
  setup, err := h.svc.SetupMFA(c.Request.Context(), currentUserID(c))
45
45
  if err != nil {
46
- c.Error(err)
46
+ c.Error(toHTTPError(err))
47
47
  return
48
48
  }
49
49
  c.JSON(http.StatusOK, mfaSetupResponse{Secret: setup.Secret, OTPAuthURI: setup.OTPAuthURI})
@@ -58,7 +58,7 @@ func (h *Handler) confirmMFA(c *gin.Context) {
58
58
  }
59
59
  codes, err := h.svc.ConfirmMFA(c.Request.Context(), currentUserID(c), in.Code)
60
60
  if err != nil {
61
- c.Error(err)
61
+ c.Error(toHTTPError(err))
62
62
  return
63
63
  }
64
64
  c.JSON(http.StatusOK, mfaConfirmResponse{RecoveryCodes: codes})
@@ -72,7 +72,7 @@ func (h *Handler) disableMFA(c *gin.Context) {
72
72
  return
73
73
  }
74
74
  if err := h.svc.DisableMFA(c.Request.Context(), currentUserID(c), in.Code); err != nil {
75
- c.Error(err)
75
+ c.Error(toHTTPError(err))
76
76
  return
77
77
  }
78
78
  c.Status(http.StatusNoContent)
@@ -1,22 +1,22 @@
1
- package user
1
+ package httpadapter
2
2
 
3
3
  import (
4
4
  "errors"
5
5
  "log/slog"
6
6
  "net/http"
7
7
 
8
- "{{goModule}}/internal/app/user/application"
8
+ userapp "{{goModule}}/internal/app/user/application"
9
9
  "{{goModule}}/internal/shared/apperror"
10
10
 
11
11
  "github.com/gin-gonic/gin"
12
12
  )
13
13
 
14
14
  func (h *Handler) providerLogin(c *gin.Context) {
15
- start, err := h.svc.BeginLogin(c.Request.Context(), c.Param("provider"), application.LoginStartInput{
15
+ start, err := h.svc.BeginLogin(c.Request.Context(), c.Param("provider"), toLoginStartInput(loginStartInput{
16
16
  State: c.Query("state"),
17
17
  CodeChallenge: c.Query("code_challenge"),
18
18
  CodeChallengeMethod: c.Query("code_challenge_method"),
19
- })
19
+ }))
20
20
  if err != nil {
21
21
  c.Error(h.oauthAppError(c.Param("provider"), err))
22
22
  return
@@ -31,10 +31,10 @@ func (h *Handler) providerExchange(c *gin.Context) {
31
31
  }
32
32
  var in loginExchangeInput
33
33
  if err := c.ShouldBindJSON(&in); err != nil {
34
- c.Error(h.oauthAppError(c.Param("provider"), application.NewOAuthError(application.OAuthFailed, err)))
34
+ c.Error(h.oauthAppError(c.Param("provider"), userapp.NewOAuthError(userapp.OAuthFailed, err)))
35
35
  return
36
36
  }
37
- auth, err := h.svc.ExchangeLogin(c.Request.Context(), c.Param("provider"), in)
37
+ auth, err := h.svc.ExchangeLogin(c.Request.Context(), c.Param("provider"), toLoginExchangeInput(in))
38
38
  if err != nil {
39
39
  c.Error(h.oauthAppError(c.Param("provider"), err))
40
40
  return
@@ -43,26 +43,26 @@ func (h *Handler) providerExchange(c *gin.Context) {
43
43
  }
44
44
 
45
45
  func (h *Handler) oauthAppError(provider string, err error) *apperror.AppError {
46
- code := application.OAuthFailed
46
+ code := userapp.OAuthFailed
47
47
  status := http.StatusBadRequest
48
48
  message := "external login failed"
49
- var oauthErr *application.OAuthError
49
+ var oauthErr *userapp.OAuthError
50
50
  if errors.As(err, &oauthErr) {
51
51
  switch oauthErr.Code {
52
- case application.OAuthDenied:
53
- code = application.OAuthDenied
52
+ case userapp.OAuthDenied:
53
+ code = userapp.OAuthDenied
54
54
  message = "external login was denied"
55
- case application.OAuthStateInvalid:
56
- code = application.OAuthStateInvalid
55
+ case userapp.OAuthStateInvalid:
56
+ code = userapp.OAuthStateInvalid
57
57
  message = "oauth state or PKCE verifier is invalid"
58
- case application.OAuthProviderUnavailable:
59
- code = application.OAuthProviderUnavailable
58
+ case userapp.OAuthProviderUnavailable:
59
+ code = userapp.OAuthProviderUnavailable
60
60
  status = http.StatusServiceUnavailable
61
61
  message = "external login provider is unavailable"
62
- case application.OAuthFailed:
63
- code = application.OAuthFailed
62
+ case userapp.OAuthFailed:
63
+ code = userapp.OAuthFailed
64
64
  default:
65
- code = application.OAuthFailed
65
+ code = userapp.OAuthFailed
66
66
  }
67
67
  }
68
68
  slog.Warn("oauth request failed", "provider", provider, "code", code, "error", err)
@@ -1,4 +1,4 @@
1
- package user
1
+ package httpadapter
2
2
 
3
3
  import (
4
4
  "net/http"
@@ -15,7 +15,7 @@ func (h *Handler) forgotPassword(c *gin.Context) {
15
15
  return
16
16
  }
17
17
  if err := h.svc.ForgotPassword(c.Request.Context(), in.Email); err != nil {
18
- c.Error(err)
18
+ c.Error(toHTTPError(err))
19
19
  return
20
20
  }
21
21
  // Always the same response, whether or not the email exists.
@@ -29,7 +29,7 @@ func (h *Handler) resetPassword(c *gin.Context) {
29
29
  return
30
30
  }
31
31
  if err := h.svc.ResetPassword(c.Request.Context(), in.Token, in.NewPassword); err != nil {
32
- c.Error(err)
32
+ c.Error(toHTTPError(err))
33
33
  return
34
34
  }
35
35
  c.Status(http.StatusNoContent)
@@ -42,7 +42,7 @@ func (h *Handler) verifyEmail(c *gin.Context) {
42
42
  return
43
43
  }
44
44
  if err := h.svc.VerifyEmail(c.Request.Context(), in.Token); err != nil {
45
- c.Error(err)
45
+ c.Error(toHTTPError(err))
46
46
  return
47
47
  }
48
48
  c.Status(http.StatusNoContent)
@@ -1,7 +1,9 @@
1
- package user
1
+ package httpadapter
2
2
 
3
3
  import (
4
4
  "context"
5
+ "crypto/sha256"
6
+ "encoding/base64"
5
7
  "net/http"
6
8
  "net/http/httptest"
7
9
  "net/url"
@@ -27,38 +29,60 @@ func (allowAllAuthorizer) Require(_ string) gin.HandlerFunc {
27
29
 
28
30
  const handlerOAuthVerifier = "client-code-verifier-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
29
31
 
30
- func newOAuthTestRouter(t *testing.T) (*gin.Engine, *fakeLoginProvider) {
31
- t.Helper()
32
- provider := &fakeLoginProvider{name: "fake"}
33
- provider.beginFn = func(in application.LoginStartInput) (application.Authorization, error) {
34
- return application.Authorization{URL: "https://provider.example.test/authorize?" + url.Values{
35
- "state": {in.State},
36
- "code_challenge": {in.CodeChallenge},
37
- "code_challenge_method": {in.CodeChallengeMethod},
38
- }.Encode()}, nil
32
+ type stubService struct {
33
+ application.ServicePort
34
+ }
35
+
36
+ func (stubService) BeginLogin(_ context.Context, provider string, in application.LoginStartInput) (*application.Authorization, error) {
37
+ if provider != "fake" {
38
+ return nil, application.NewOAuthError(application.OAuthProviderUnavailable, nil)
39
39
  }
40
- provider.completeFn = func(application.LoginCompleteInput) (application.ExternalIdentity, error) {
41
- return application.ExternalIdentity{Provider: "fake", Subject: "subject", Email: "user@example.com", EmailVerified: true, Name: "User"}, nil
40
+ return &application.Authorization{URL: "https://provider.example.test/authorize?" + url.Values{
41
+ "state": {in.State},
42
+ "code_challenge": {in.CodeChallenge},
43
+ "code_challenge_method": {in.CodeChallengeMethod},
44
+ }.Encode()}, nil
45
+ }
46
+
47
+ func (stubService) ExchangeLogin(_ context.Context, provider string, in application.LoginExchangeInput) (*application.AuthResult, error) {
48
+ if provider != "fake" {
49
+ return nil, application.NewOAuthError(application.OAuthProviderUnavailable, nil)
42
50
  }
43
- svc := newTestServiceWithProviders(&fakeRepo{}, newFakeTokenStore(), provider)
51
+ if in.State == "" || in.CodeVerifier == "" {
52
+ return nil, application.NewOAuthError(application.OAuthStateInvalid, nil)
53
+ }
54
+ if in.Code == "" {
55
+ return nil, application.NewOAuthError(application.OAuthFailed, nil)
56
+ }
57
+ return application.TokenResult(&application.AuthResponse{
58
+ AccessToken: "access-token",
59
+ RefreshToken: "refresh-token",
60
+ TokenType: "Bearer",
61
+ ExpiresIn: 900,
62
+ }), nil
63
+ }
64
+
65
+ func (stubService) Logout(context.Context, string) error { return nil }
66
+
67
+ func pkceChallenge(verifier string) string {
68
+ sum := sha256.Sum256([]byte(verifier))
69
+ return base64.RawURLEncoding.EncodeToString(sum[:])
70
+ }
71
+
72
+ func newOAuthTestRouter(t *testing.T) *gin.Engine {
73
+ t.Helper()
74
+ svc := stubService{}
44
75
  h := NewHandler(svc, "test-secret", time.Hour, true, "strict", allowAllLimiter{}, allowAllAuthorizer{})
45
76
  gin.SetMode(gin.TestMode)
46
77
  router := gin.New()
47
78
  router.Use(middleware.Error(false))
48
79
  h.Register(router)
49
- return router, provider
80
+ return router
50
81
  }
51
82
 
52
83
  func newCrossSiteTestRouter(t *testing.T) *gin.Engine {
53
84
  t.Helper()
54
- provider := &fakeLoginProvider{name: "fake"}
55
- provider.beginFn = func(in application.LoginStartInput) (application.Authorization, error) {
56
- return application.Authorization{URL: "https://provider.example.test/authorize?state=" + url.QueryEscape(in.State)}, nil
57
- }
58
- provider.completeFn = func(application.LoginCompleteInput) (application.ExternalIdentity, error) {
59
- return application.ExternalIdentity{Provider: "fake", Subject: "subject", Email: "user@example.com", EmailVerified: true}, nil
60
- }
61
- svc := newTestServiceWithProviders(&fakeRepo{}, newFakeTokenStore(), provider)
85
+ svc := stubService{}
62
86
  h := NewHandlerWithOrigins(svc, "test-secret", time.Hour, true, "none", []string{"https://app.example.test"}, allowAllLimiter{}, allowAllAuthorizer{})
63
87
  router := gin.New()
64
88
  router.Use(middleware.Error(false))
@@ -94,10 +118,10 @@ func beginOAuthFlow(t *testing.T, router *gin.Engine) (string, string) {
94
118
  }
95
119
 
96
120
  func TestHandler_ProviderExchangeReturnsJSONAndSetsRefreshCookie(t *testing.T) {
97
- router, _ := newOAuthTestRouter(t)
121
+ router := newOAuthTestRouter(t)
98
122
  state, verifier := beginOAuthFlow(t, router)
99
123
  request := httptest.NewRequest(http.MethodPost, "/auth/fake/exchange", strings.NewReader(
100
- "{\"code\":\"code\",\"state\":\"" + state + "\",\"code_verifier\":\"" + verifier + "\"}",
124
+ "{\"code\":\"code\",\"state\":\""+state+"\",\"code_verifier\":\""+verifier+"\"}",
101
125
  ))
102
126
  request.Header.Set("Content-Type", "application/json")
103
127
  response := httptest.NewRecorder()
@@ -128,16 +152,13 @@ func TestHandler_ProviderExchangeReturnsJSONAndSetsRefreshCookie(t *testing.T) {
128
152
  }
129
153
 
130
154
  func TestHandler_ProviderCallbackIsFrontendOwnedAndErrorsDoNotRedirect(t *testing.T) {
131
- router, provider := newOAuthTestRouter(t)
155
+ router := newOAuthTestRouter(t)
132
156
  callback := httptest.NewRequest(http.MethodGet, "/auth/fake/callback?error=access_denied&error_description=do-not-leak", nil)
133
157
  callbackResponse := httptest.NewRecorder()
134
158
  router.ServeHTTP(callbackResponse, callback)
135
159
  if callbackResponse.Code != http.StatusNotFound {
136
160
  t.Fatalf("API callback status = %d, want 404 for frontend-owned callback", callbackResponse.Code)
137
161
  }
138
- if provider.completeCnt != 0 {
139
- t.Fatal("provider must not exchange a frontend callback error")
140
- }
141
162
 
142
163
  invalid := httptest.NewRequest(http.MethodPost, "/auth/fake/exchange?return_to=https%3A%2F%2Fevil.example", strings.NewReader(
143
164
  "{\"code\":\"code\",\"state\":\"\",\"code_verifier\":\""+handlerOAuthVerifier+"\",\"error_description\":\"do-not-leak\"}",
@@ -154,7 +175,7 @@ func TestHandler_ProviderCallbackIsFrontendOwnedAndErrorsDoNotRedirect(t *testin
154
175
  }
155
176
 
156
177
  func TestHandler_ProviderLoginAndExchangeUnconfiguredProviderUseControlledJSON(t *testing.T) {
157
- router, _ := newOAuthTestRouter(t)
178
+ router := newOAuthTestRouter(t)
158
179
  login := httptest.NewRequest(http.MethodGet, "/auth/google/login?"+url.Values{
159
180
  "state": {"client-state"},
160
181
  "code_challenge": {"client-code-challenge"},
@@ -184,7 +205,7 @@ func TestHandler_ProviderLoginAndExchangeUnconfiguredProviderUseControlledJSON(t
184
205
  }
185
206
 
186
207
  func TestHandler_ProviderExchangeRejectsMissingStatePKCEAndCode(t *testing.T) {
187
- router, _ := newOAuthTestRouter(t)
208
+ router := newOAuthTestRouter(t)
188
209
  tests := []struct {
189
210
  name string
190
211
  body string
@@ -231,7 +252,7 @@ func TestHandler_ValidateBrowserCookiePolicy(t *testing.T) {
231
252
  }
232
253
  for _, tt := range tests {
233
254
  t.Run(tt.name, func(t *testing.T) {
234
- err := validateBrowserCookiePolicy(tt.topology, tt.sameSite, tt.secure, tt.production)
255
+ err := ValidateBrowserCookiePolicy(tt.topology, tt.sameSite, tt.secure, tt.production)
235
256
  if (err != nil) != tt.wantErr {
236
257
  t.Fatalf("validateBrowserCookiePolicy() error = %v, wantErr %v", err, tt.wantErr)
237
258
  }
@@ -1,4 +1,4 @@
1
- package user
1
+ package httpadapter
2
2
 
3
3
  import (
4
4
  "net/http"
@@ -12,7 +12,7 @@ import (
12
12
  func (h *Handler) resendVerification(c *gin.Context) {
13
13
  userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
14
14
  if err := h.svc.ResendVerificationEmail(c.Request.Context(), userID); err != nil {
15
- c.Error(err)
15
+ c.Error(toHTTPError(err))
16
16
  return
17
17
  }
18
18
  c.Status(http.StatusNoContent)
@@ -23,7 +23,7 @@ func (h *Handler) resendVerification(c *gin.Context) {
23
23
  func (h *Handler) logoutAll(c *gin.Context) {
24
24
  userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
25
25
  if err := h.svc.LogoutAll(c.Request.Context(), userID); err != nil {
26
- c.Error(err)
26
+ c.Error(toHTTPError(err))
27
27
  return
28
28
  }
29
29
  h.clearRefreshCookie(c)
@@ -34,7 +34,7 @@ func (h *Handler) me(c *gin.Context) {
34
34
  userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
35
35
  u, err := h.svc.Get(c.Request.Context(), userID)
36
36
  if err != nil {
37
- c.Error(err)
37
+ c.Error(toHTTPError(err))
38
38
  return
39
39
  }
40
40
  c.JSON(http.StatusOK, toMeResponse(u))
@@ -1,8 +1,10 @@
1
- package user
1
+ package httpadapter
2
2
 
3
3
  import (
4
4
  "net/http"
5
5
 
6
+ "{{goModule}}/internal/app/user/application"
7
+
6
8
  "github.com/gin-gonic/gin"
7
9
  )
8
10
 
@@ -23,11 +25,11 @@ func setNoStoreHeaders(c *gin.Context) {
23
25
  c.Header("Pragma", "no-cache")
24
26
  }
25
27
 
26
- func (h *Handler) writeAuthResult(c *gin.Context, status int, result *authResult) {
28
+ func (h *Handler) writeAuthResult(c *gin.Context, status int, result *application.AuthResult) {
27
29
  if result.MFAChallenge != "" {
28
30
  c.JSON(http.StatusOK, mfaChallengeResponse{MFARequired: true, Challenge: result.MFAChallenge})
29
31
  return
30
32
  }
31
33
  h.setRefreshCookie(c, result.RefreshToken)
32
- c.JSON(status, toCookieResponse(result.authResponse))
34
+ c.JSON(status, toCookieResponse(result.AuthResponse))
33
35
  }
@@ -0,0 +1,35 @@
1
+ package password
2
+
3
+ import (
4
+ userports "{{goModule}}/internal/app/user/ports"
5
+
6
+ "golang.org/x/crypto/bcrypt"
7
+ )
8
+
9
+ // BCryptHasher is the production password adapter. Keeping bcrypt here means
10
+ // register, login, reset, and seed all share one algorithm boundary.
11
+ type BCryptHasher struct {
12
+ cost int
13
+ }
14
+
15
+ func NewBCryptHasher() *BCryptHasher {
16
+ return &BCryptHasher{cost: bcrypt.DefaultCost}
17
+ }
18
+
19
+ func (h *BCryptHasher) Hash(password string) (string, error) {
20
+ cost := h.cost
21
+ if cost <= 0 {
22
+ cost = bcrypt.DefaultCost
23
+ }
24
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
25
+ if err != nil {
26
+ return "", err
27
+ }
28
+ return string(hash), nil
29
+ }
30
+
31
+ func (*BCryptHasher) Compare(hash, password string) error {
32
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
33
+ }
34
+
35
+ var _ userports.PasswordHasher = (*BCryptHasher)(nil)
@@ -0,0 +1,20 @@
1
+ package password
2
+
3
+ import "testing"
4
+
5
+ func TestBCryptHasherRoundTrip(t *testing.T) {
6
+ hasher := NewBCryptHasher()
7
+ hash, err := hasher.Hash("correct horse battery staple")
8
+ if err != nil {
9
+ t.Fatalf("hash password: %v", err)
10
+ }
11
+ if hash == "" {
12
+ t.Fatal("expected a password hash")
13
+ }
14
+ if err := hasher.Compare(hash, "correct horse battery staple"); err != nil {
15
+ t.Fatalf("compare matching password: %v", err)
16
+ }
17
+ if err := hasher.Compare(hash, "wrong password"); err == nil {
18
+ t.Fatal("expected a mismatched password to be rejected")
19
+ }
20
+ }