@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,99 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "time"
7
+
8
+ "{{goModule}}/internal/app/user/domain"
9
+
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ // Refresh rotates a refresh token. A replayed, already-used token revokes
14
+ // every session for that user; an intentionally logged-out token is rejected
15
+ // without triggering reuse detection.
16
+ func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*AuthResponse, error) {
17
+ hash := hashToken(rawRefreshToken)
18
+ token, ok, err := s.refreshTokens.ConsumeRefreshToken(ctx, hash)
19
+ if err != nil {
20
+ return nil, fmt.Errorf("consume refresh token: %w", err)
21
+ }
22
+ if !ok {
23
+ reusedBy, used, reuseErr := s.refreshTokens.IsRefreshTokenUsed(ctx, hash)
24
+ if reuseErr != nil {
25
+ return nil, fmt.Errorf("check refresh token reuse: %w", reuseErr)
26
+ }
27
+ if used {
28
+ if revokeErr := s.refreshTokens.RevokeAllRefreshTokens(ctx, reusedBy); revokeErr != nil {
29
+ return nil, fmt.Errorf("revoke sessions after refresh token reuse: %w", revokeErr)
30
+ }
31
+ }
32
+ return nil, errInvalidToken()
33
+ }
34
+
35
+ u, err := s.repo.FindByID(ctx, token.UserID)
36
+ if err != nil {
37
+ return nil, errInvalidToken()
38
+ }
39
+ return s.issueTokens(ctx, u, token.AbsoluteExpiresAt)
40
+ }
41
+
42
+ // Logout is intentional, not reuse: a missing or already-gone token still
43
+ // succeeds and leaves no tombstone.
44
+ func (s *Service) Logout(ctx context.Context, rawRefreshToken string) error {
45
+ if rawRefreshToken == "" {
46
+ return nil
47
+ }
48
+ hash := hashToken(rawRefreshToken)
49
+ userID, ok, err := s.refreshTokens.GetRefreshToken(ctx, hash)
50
+ if err != nil || !ok {
51
+ return nil
52
+ }
53
+ return s.refreshTokens.DeleteRefreshToken(ctx, hash, userID)
54
+ }
55
+
56
+ // LogoutAll ends every refresh session for the authenticated user.
57
+ func (s *Service) LogoutAll(ctx context.Context, userID uuid.UUID) error {
58
+ return s.refreshTokens.RevokeAllRefreshTokens(ctx, userID)
59
+ }
60
+
61
+ func (s *Service) issueTokens(ctx context.Context, u *domain.User, absoluteExpiresAt ...time.Time) (*AuthResponse, error) {
62
+ access, err := s.issueAccessToken(
63
+ u.ID,
64
+ u.Role,
65
+ // go-scaffold:issue-access-token-args
66
+ )
67
+ if err != nil {
68
+ return nil, fmt.Errorf("issue access token: %w", err)
69
+ }
70
+ refresh, err := randomToken()
71
+ if err != nil {
72
+ return nil, fmt.Errorf("generate refresh token: %w", err)
73
+ }
74
+ clock := s.now
75
+ if clock == nil {
76
+ clock = time.Now
77
+ }
78
+ now := clock()
79
+ abs := now.Add(s.config.JWTRefreshMaxTTL)
80
+ if len(absoluteExpiresAt) > 0 && !absoluteExpiresAt[0].IsZero() {
81
+ abs = absoluteExpiresAt[0]
82
+ }
83
+ expiresAt := now.Add(s.config.JWTRefreshTTL)
84
+ if abs.Before(expiresAt) {
85
+ expiresAt = abs
86
+ }
87
+ if !expiresAt.After(now) || !abs.After(now) {
88
+ return nil, fmt.Errorf("refresh token lifetime is exhausted")
89
+ }
90
+ if err := s.refreshTokens.SetRefreshToken(ctx, hashToken(refresh), RefreshTokenRecord{UserID: u.ID, ExpiresAt: expiresAt, AbsoluteExpiresAt: abs}); err != nil {
91
+ return nil, fmt.Errorf("store refresh token: %w", err)
92
+ }
93
+ return &AuthResponse{
94
+ AccessToken: access,
95
+ RefreshToken: refresh,
96
+ TokenType: "Bearer",
97
+ ExpiresIn: int(s.config.JWTAccessTTL.Seconds()),
98
+ }, nil
99
+ }
@@ -0,0 +1,14 @@
1
+ package application
2
+
3
+ import "{{goModule}}/internal/app/user/ports"
4
+
5
+ // These aliases keep the use-case files readable while the actual outbound
6
+ // contracts remain owned by the user module's ports package.
7
+ type RefreshTokenStore = ports.RefreshTokenStore
8
+ type OAuthTransactionStore = ports.OAuthTransactionStore
9
+ type RecoveryTokenStore = ports.RecoveryTokenStore
10
+ type RefreshTokenRecord = ports.RefreshTokenRecord
11
+ type LoginTransaction = ports.LoginTransaction
12
+ type MFAEnrollment = ports.MFAEnrollment
13
+ type MFAChallenge = ports.MFAChallenge
14
+ type MFAStore = ports.MFAStore
@@ -0,0 +1,65 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+
8
+ "{{goModule}}/internal/app/user/domain"
9
+ "{{goModule}}/internal/shared/id"
10
+
11
+ "github.com/google/uuid"
12
+ )
13
+
14
+ // wrapFindErr is shared by methods added through `generate method`. Keeping
15
+ // not-found translation in this query file means the small service facade
16
+ // does not need to import database-specific error packages just because a
17
+ // later method was generated.
18
+ func wrapFindErr(err error) error {
19
+ if errors.Is(err, domain.ErrNotFound) {
20
+ return errNotFound()
21
+ }
22
+ return fmt.Errorf("find user: %w", err)
23
+ }
24
+
25
+ func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*domain.User, error) {
26
+ u, err := s.repo.FindByID(ctx, userID)
27
+ if err != nil {
28
+ return nil, wrapFindErr(err)
29
+ }
30
+ return u, nil
31
+ }
32
+
33
+ // List is kept for the optional RBAC admin routes. It is not part of the
34
+ // public self-service auth surface until that feature is installed.
35
+ func (s *Service) List(ctx context.Context, limit, offset int) ([]domain.User, error) {
36
+ items, err := s.repo.FindAll(ctx, limit, offset)
37
+ if err != nil {
38
+ return nil, fmt.Errorf("list users: %w", err)
39
+ }
40
+ return items, nil
41
+ }
42
+
43
+ // EnsureUser is idempotent and used only by cmd/seed. It never resets an
44
+ // existing user's password as a side effect of a later deployment.
45
+ func (s *Service) EnsureUser(ctx context.Context, email, password, name string) (*domain.User, error) {
46
+ email = normalizeEmail(email)
47
+ if u, err := s.repo.FindByEmail(ctx, email); err == nil {
48
+ return u, nil
49
+ } else if !errors.Is(err, domain.ErrNotFound) {
50
+ return nil, fmt.Errorf("find existing user: %w", err)
51
+ }
52
+
53
+ hash, err := s.passwords.Hash(password)
54
+ if err != nil {
55
+ return nil, fmt.Errorf("hash password: %w", err)
56
+ }
57
+ hashStr := string(hash)
58
+
59
+ u := &domain.User{ID: id.New(), Email: email, Name: name, Role: domain.DefaultRole}
60
+ i := &domain.Identity{ID: id.New(), Provider: domain.ProviderLocal, PasswordHash: &hashStr}
61
+ if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
62
+ return nil, fmt.Errorf("create user: %w", err)
63
+ }
64
+ return u, nil
65
+ }
@@ -0,0 +1,168 @@
1
+ package user
2
+
3
+ import (
4
+ "fmt"
5
+ "time"
6
+
7
+ httpadapter "{{goModule}}/internal/app/user/adapters/inbound/http"
8
+ userpassword "{{goModule}}/internal/app/user/adapters/outbound/password"
9
+ userpostgres "{{goModule}}/internal/app/user/adapters/outbound/postgres"
10
+ "{{goModule}}/internal/app/user/application"
11
+ "{{goModule}}/internal/app/user/ports"
12
+ googleprovider "{{goModule}}/internal/platform/authprovider/google"
13
+ "{{goModule}}/internal/platform/mail"
14
+ {{#if worker}}
15
+ "{{goModule}}/internal/platform/queue"
16
+ {{/if}}
17
+ "{{goModule}}/internal/shared/config"
18
+ "{{goModule}}/internal/shared/middleware"
19
+
20
+ {{#if redis}}
21
+ userredis "{{goModule}}/internal/app/user/adapters/outbound/redis"
22
+ "github.com/redis/go-redis/v9"
23
+ {{/if}}
24
+ "github.com/gin-gonic/gin"
25
+ "gorm.io/gorm"
26
+ )
27
+
28
+ // NewHandlerFromDB is the user module's composition root. It assembles the
29
+ // application service and adapters here; cmd/api only supplies process-level
30
+ // infrastructure and registers the returned inbound handler.
31
+ func NewHandlerFromDB(
32
+ db *gorm.DB,
33
+ cfg config.Config,
34
+ {{#if redis}}
35
+ rdb *redis.Client,
36
+ {{/if}}
37
+ {{#if worker}}
38
+ q queue.Enqueuer,
39
+ {{/if}}
40
+ roleChecker RoleChecker,
41
+ authz Authorizer,
42
+ ) *httpadapter.Handler {
43
+ {{#if redis}}
44
+ tokens := userredis.NewRedisTokenStore(rdb, db)
45
+ {{else}}
46
+ tokens := userpostgres.NewPgTokenStore(db)
47
+ {{/if}}
48
+
49
+ {{#if worker}}
50
+ mailer := mail.NewAsyncClient(q)
51
+ {{else}}
52
+ mailer := mail.NewSyncClient(mail.Open(cfg))
53
+ {{/if}}
54
+
55
+ providers := make([]application.LoginProvider, 0, 1)
56
+ if cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "" && cfg.GoogleOAuthRedirectURI != "" {
57
+ providers = append(providers, googleprovider.New(googleprovider.Config{
58
+ ClientID: cfg.GoogleClientID,
59
+ ClientSecret: cfg.GoogleClientSecret,
60
+ RedirectURL: cfg.GoogleOAuthRedirectURI,
61
+ }))
62
+ }
63
+ providerRegistry := application.NewProviderRegistry(providers...)
64
+ if err := httpadapter.ValidateBrowserCookiePolicy(cfg.AuthBrowserTopology, cfg.CookieSameSite, cfg.CookieSecure, cfg.IsProd()); err != nil {
65
+ panic(err)
66
+ }
67
+ if cfg.JWTRefreshTTL <= 0 || cfg.JWTRefreshMaxTTL <= 0 {
68
+ panic(fmt.Errorf("JWT refresh token lifetimes must be positive"))
69
+ }
70
+ if cfg.OAuthStateTTL <= 0 {
71
+ panic(fmt.Errorf("OAUTH_STATE_TTL_MIN must be positive"))
72
+ }
73
+
74
+ service := application.NewService(application.Dependencies{
75
+ Repository: userpostgres.NewRepository(db),
76
+ Passwords: userpassword.NewBCryptHasher(),
77
+ RefreshTokens: tokens,
78
+ OAuthTransactions: tokens,
79
+ RecoveryTokens: tokens,
80
+ MFA: userpostgres.NewPostgresMFAStore(db),
81
+ Mailer: mailer,
82
+ Providers: providerRegistry,
83
+ Roles: roleChecker,
84
+ Clock: time.Now,
85
+ }, application.AuthConfig{
86
+ JWTSecret: cfg.JWTSecret,
87
+ JWTAccessTTL: cfg.JWTAccessTTL,
88
+ JWTRefreshTTL: cfg.JWTRefreshTTL,
89
+ JWTRefreshMaxTTL: cfg.JWTRefreshMaxTTL,
90
+ OAuthStateTTL: cfg.OAuthStateTTL,
91
+ PasswordResetTTL: cfg.PasswordResetTTL,
92
+ PasswordResetURL: cfg.PasswordResetURL,
93
+ EmailVerifyTTL: cfg.EmailVerifyTTL,
94
+ EmailVerifyURL: cfg.EmailVerifyURL,
95
+ MFA: application.MFASettings{
96
+ Enabled: cfg.AuthMFAEnabled,
97
+ Issuer: cfg.MFAIssuer,
98
+ EncryptionKey: cfg.MFAEncryptionKey,
99
+ ChallengeTTL: cfg.MFAChallengeTTL,
100
+ TOTPWindow: cfg.MFATOTPWindow,
101
+ RecoveryCodeCount: cfg.MFARecoveryCodeCount,
102
+ },
103
+ })
104
+ {{#if redis}}
105
+ limiter := middleware.NewRedisLimiter(rdb)
106
+ {{else}}
107
+ limiter := middleware.NewMemoryLimiter()
108
+ {{/if}}
109
+ return httpadapter.NewHandlerWithOrigins(
110
+ service,
111
+ cfg.JWTSecret,
112
+ cfg.JWTRefreshTTL,
113
+ cfg.CookieSecure,
114
+ cfg.CookieSameSite,
115
+ cfg.CORSAllowedOrigins,
116
+ limiter,
117
+ authz,
118
+ )
119
+ }
120
+
121
+ // Public aliases keep the composition surface small for cmd/seed and for
122
+ // optional RBAC wiring. Consumers do not need to know adapter package names.
123
+ type Dependencies = application.Dependencies
124
+ type AuthConfig = application.AuthConfig
125
+ type MFASettings = application.MFASettings
126
+ type ProviderRegistry = application.ProviderRegistry
127
+ type RoleChecker = ports.RoleChecker
128
+ type Service = application.Service
129
+ type ServicePort = application.ServicePort
130
+ type Repository = userpostgres.Repository
131
+ {{#unless redis}}
132
+ type PgTokenStore = userpostgres.PgTokenStore
133
+ {{/unless}}
134
+ type PostgresMFAStore = userpostgres.PostgresMFAStore
135
+ {{#if redis}}
136
+ type RedisTokenStore = userredis.RedisTokenStore
137
+ {{/if}}
138
+
139
+ type Authorizer interface {
140
+ Require(string) gin.HandlerFunc
141
+ }
142
+
143
+ func NewService(deps Dependencies, cfg AuthConfig) *Service {
144
+ return application.NewService(deps, cfg)
145
+ }
146
+
147
+ func ValidateMFASettings(settings MFASettings) error {
148
+ return application.ValidateMFASettings(settings)
149
+ }
150
+
151
+ func NewRepository(db *gorm.DB) *Repository {
152
+ return userpostgres.NewRepository(db)
153
+ }
154
+
155
+ {{#unless redis}}
156
+ func NewPgTokenStore(db *gorm.DB) *PgTokenStore {
157
+ return userpostgres.NewPgTokenStore(db)
158
+ }
159
+ {{/unless}}
160
+
161
+ func NewPostgresMFAStore(db *gorm.DB) *PostgresMFAStore {
162
+ return userpostgres.NewPostgresMFAStore(db)
163
+ }
164
+ {{#if redis}}
165
+ func NewRedisTokenStore(rdb *redis.Client, db *gorm.DB) *RedisTokenStore {
166
+ return userredis.NewRedisTokenStore(rdb, db)
167
+ }
168
+ {{/if}}
@@ -0,0 +1,41 @@
1
+ // Package domain contains transport- and persistence-neutral user values.
2
+ package domain
3
+
4
+ import (
5
+ "time"
6
+
7
+ "github.com/google/uuid"
8
+ )
9
+
10
+ type User struct {
11
+ ID uuid.UUID
12
+ Email string
13
+ Name string
14
+ AvatarURL string
15
+ EmailVerified bool
16
+ Role string
17
+ CreatedAt time.Time
18
+ UpdatedAt time.Time
19
+ }
20
+
21
+ // DefaultRole is part of auth's persisted user contract. RBAC may add the
22
+ // role catalog later, but every newly issued access token must carry the same
23
+ // default that the users migration applies at the database boundary.
24
+ const DefaultRole = "staff"
25
+
26
+ type Provider string
27
+
28
+ const (
29
+ ProviderLocal Provider = "local"
30
+ ProviderGoogle Provider = "google"
31
+ )
32
+
33
+ type Identity struct {
34
+ ID uuid.UUID
35
+ UserID uuid.UUID
36
+ Provider Provider
37
+ PasswordHash *string
38
+ ProviderUID *string
39
+ CreatedAt time.Time
40
+ UpdatedAt time.Time
41
+ }
@@ -0,0 +1,32 @@
1
+ package domain
2
+
3
+ import "errors"
4
+
5
+ var (
6
+ ErrNotFound = errors.New("user not found")
7
+ ErrConflict = errors.New("user conflict")
8
+ ErrInvalidCredential = errors.New("invalid credentials")
9
+ ErrInvalidToken = errors.New("invalid or expired token")
10
+ ErrEmailTaken = errors.New("email already registered")
11
+ ErrTooManyAttempts = errors.New("too many failed attempts")
12
+ ErrAlreadyVerified = errors.New("email already verified")
13
+ ErrMFAUnavailable = errors.New("MFA is unavailable")
14
+ ErrMFAInvalid = errors.New("invalid or expired MFA code")
15
+ ErrMFAAlreadyEnabled = errors.New("MFA is already enabled")
16
+ ErrMFANotEnrolled = errors.New("MFA is not enrolled")
17
+ ErrMFASetupRequired = errors.New("MFA setup is required")
18
+ ErrUnknownRole = errors.New("unknown role")
19
+ )
20
+
21
+ type RuleError struct {
22
+ Code string
23
+ Message string
24
+ Cause error
25
+ }
26
+
27
+ func (e *RuleError) Error() string { return e.Message }
28
+ func (e *RuleError) Unwrap() error { return e.Cause }
29
+
30
+ func Rule(code, message string, cause error) error {
31
+ return &RuleError{Code: code, Message: message, Cause: cause}
32
+ }
@@ -0,0 +1,9 @@
1
+ package ports
2
+
3
+ // PasswordHasher is the application-owned outbound contract for password
4
+ // storage and verification. The algorithm and cost policy belong to an
5
+ // adapter, not to the auth use cases.
6
+ type PasswordHasher interface {
7
+ Hash(string) (string, error)
8
+ Compare(hash, password string) error
9
+ }
@@ -0,0 +1,90 @@
1
+ // Package ports contains user auth's application-owned outbound contracts.
2
+ package ports
3
+
4
+ import (
5
+ "context"
6
+ "time"
7
+
8
+ "{{goModule}}/internal/app/user/domain"
9
+
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ type UserRepository interface {
14
+ FindByEmail(context.Context, string) (*domain.User, error)
15
+ FindByID(context.Context, uuid.UUID) (*domain.User, error)
16
+ UpdateUser(context.Context, *domain.User) error
17
+ FindAll(context.Context, int, int) ([]domain.User, error)
18
+ FindIdentity(context.Context, uuid.UUID, domain.Provider) (*domain.Identity, error)
19
+ FindIdentityByProviderUID(context.Context, domain.Provider, string) (*domain.Identity, error)
20
+ CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error
21
+ CreateIdentity(context.Context, *domain.Identity) error
22
+ UpdateIdentity(context.Context, *domain.Identity) error
23
+ LoginLockedUntil(context.Context, string) (time.Time, error)
24
+ RecordLoginFailure(context.Context, string, int, time.Duration) error
25
+ ClearLoginFailures(context.Context, string) error
26
+ // go-scaffold:repository-interface
27
+ }
28
+
29
+ type AuthMailer interface {
30
+ Send(context.Context, string, string, string) error
31
+ }
32
+
33
+ type RoleChecker interface {
34
+ CodeExists(context.Context, string) (bool, error)
35
+ }
36
+
37
+ type RefreshTokenRecord struct {
38
+ UserID uuid.UUID `json:"user_id"`
39
+ ExpiresAt time.Time `json:"expires_at"`
40
+ AbsoluteExpiresAt time.Time `json:"absolute_expires_at"`
41
+ }
42
+
43
+ type RefreshTokenStore interface {
44
+ SetRefreshToken(context.Context, string, RefreshTokenRecord) error
45
+ GetRefreshToken(context.Context, string) (uuid.UUID, bool, error)
46
+ ConsumeRefreshToken(context.Context, string) (RefreshTokenRecord, bool, error)
47
+ DeleteRefreshToken(context.Context, string, uuid.UUID) error
48
+ RevokeAllRefreshTokens(context.Context, uuid.UUID) error
49
+ IsRefreshTokenUsed(context.Context, string) (uuid.UUID, bool, error)
50
+ }
51
+
52
+ type LoginTransaction struct {
53
+ Provider string `json:"provider"`
54
+ CodeChallenge string `json:"code_challenge"`
55
+ Nonce string `json:"nonce"`
56
+ ExpiresAt time.Time `json:"expires_at"`
57
+ }
58
+
59
+ type OAuthTransactionStore interface {
60
+ SetLoginTransaction(context.Context, string, LoginTransaction) error
61
+ ConsumeLoginTransaction(context.Context, string) (LoginTransaction, bool, error)
62
+ }
63
+
64
+ type RecoveryTokenStore interface {
65
+ WithTransaction(context.Context, func(context.Context) error) error
66
+ ConsumePasswordResetToken(context.Context, string) (uuid.UUID, bool, error)
67
+ ConsumeEmailVerifyToken(context.Context, string) (uuid.UUID, bool, error)
68
+ SetPasswordResetToken(context.Context, string, uuid.UUID, time.Duration) error
69
+ SetEmailVerifyToken(context.Context, string, uuid.UUID, time.Duration) error
70
+ }
71
+
72
+ type MFAEnrollment struct {
73
+ EncryptedSecret string
74
+ Enabled bool
75
+ }
76
+
77
+ type MFAChallenge struct {
78
+ UserID uuid.UUID
79
+ ExpiresAt time.Time
80
+ }
81
+
82
+ type MFAStore interface {
83
+ GetEnrollment(context.Context, uuid.UUID) (MFAEnrollment, bool, error)
84
+ PutPendingEnrollment(context.Context, uuid.UUID, string) error
85
+ ConfirmEnrollment(context.Context, uuid.UUID, string, []string) error
86
+ Disable(context.Context, uuid.UUID) error
87
+ CreateChallenge(context.Context, string, MFAChallenge) error
88
+ ConsumeChallenge(context.Context, string) (MFAChallenge, bool, error)
89
+ ConsumeRecoveryCode(context.Context, uuid.UUID, string) (bool, error)
90
+ }