@nakedev/go-scaffold 0.4.3 → 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 (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,134 +0,0 @@
1
- package user
2
-
3
- import (
4
- "time"
5
-
6
- "{{goModule}}/internal/app/user/model"
7
-
8
- "github.com/google/uuid"
9
- )
10
-
11
- type registerInput struct {
12
- Email string `json:"email" binding:"required,email"`
13
- Password string `json:"password" binding:"required,min=8"`
14
- Name string `json:"name" binding:"required"`
15
- }
16
-
17
- type loginInput struct {
18
- Email string `json:"email" binding:"required,email"`
19
- Password string `json:"password" binding:"required"`
20
- }
21
-
22
- type forgotPasswordInput struct {
23
- Email string `json:"email" binding:"required,email"`
24
- }
25
-
26
- type resetPasswordInput struct {
27
- Token string `json:"token" binding:"required"`
28
- NewPassword string `json:"new_password" binding:"required,min=8"`
29
- }
30
-
31
- type verifyEmailInput struct {
32
- Token string `json:"token" binding:"required"`
33
- }
34
-
35
- type mfaCodeInput struct {
36
- Code string `json:"code" binding:"required"`
37
- }
38
-
39
- type mfaChallengeInput struct {
40
- Challenge string `json:"challenge" binding:"required"`
41
- Code string `json:"code" binding:"required"`
42
- }
43
-
44
- // authResponse is what the service returns internally (both tokens); the
45
- // handler sends the refresh token as an httpOnly cookie instead of echoing
46
- // it in the body, so meResponse never carries it — see toCookieResponse.
47
- type authResponse struct {
48
- AccessToken string `json:"access_token"`
49
- RefreshToken string `json:"-"`
50
- TokenType string `json:"token_type"`
51
- ExpiresIn int `json:"expires_in"`
52
- }
53
-
54
- // authResult keeps the normal token shape source-compatible for callers while
55
- // representing the pre-session state required by MFA. A challenge never
56
- // carries an access or refresh token.
57
- type authResult struct {
58
- *authResponse
59
- MFAChallenge string
60
- }
61
-
62
- func tokenResult(auth *authResponse) *authResult { return &authResult{authResponse: auth} }
63
-
64
- func challengeResult(challenge string) *authResult { return &authResult{MFAChallenge: challenge} }
65
-
66
- type authCookieResponse struct {
67
- AccessToken string `json:"access_token"`
68
- TokenType string `json:"token_type"`
69
- ExpiresIn int `json:"expires_in"`
70
- }
71
-
72
- type mfaChallengeResponse struct {
73
- MFARequired bool `json:"mfa_required"`
74
- Challenge string `json:"challenge"`
75
- }
76
-
77
- type mfaStatusResponse struct {
78
- Available bool `json:"available"`
79
- Enabled bool `json:"enabled"`
80
- }
81
-
82
- type mfaSetupResponse struct {
83
- Secret string `json:"secret"`
84
- OTPAuthURI string `json:"otpauth_uri"`
85
- }
86
-
87
- type mfaConfirmResponse struct {
88
- RecoveryCodes []string `json:"recovery_codes"`
89
- }
90
-
91
- func toCookieResponse(a *authResponse) authCookieResponse {
92
- return authCookieResponse{AccessToken: a.AccessToken, TokenType: a.TokenType, ExpiresIn: a.ExpiresIn}
93
- }
94
-
95
- type meResponse struct {
96
- ID uuid.UUID `json:"id"`
97
- Email string `json:"email"`
98
- Name string `json:"name"`
99
- AvatarURL string `json:"avatar_url"`
100
- EmailVerified bool `json:"email_verified"`
101
- // go-scaffold:user-me-fields
102
- CreatedAt time.Time `json:"created_at"`
103
- }
104
-
105
- func toMeResponse(u *model.User) meResponse {
106
- return meResponse{
107
- ID: u.ID,
108
- Email: u.Email,
109
- Name: u.Name,
110
- AvatarURL: u.AvatarURL,
111
- EmailVerified: u.EmailVerified,
112
- CreatedAt: u.CreatedAt,
113
- // go-scaffold:user-me-values
114
- }
115
- }
116
-
117
- // response is the small public profile DTO used by endpoints added through
118
- // `generate method user ...`. Auth's built-in /users/me response intentionally
119
- // stays separate because it has a different contract, but generated admin or
120
- // support lookups still need the same stable id/timestamp shape as ordinary
121
- // modules.
122
- //
123
- //nolint:unused
124
- type response struct {
125
- ID uuid.UUID `json:"id"`
126
- CreatedAt time.Time `json:"created_at"`
127
- }
128
-
129
- //nolint:unused
130
- func toResponse(u *model.User) response {
131
- return response{ID: u.ID, CreatedAt: u.CreatedAt}
132
- }
133
-
134
- // go-scaffold:user-dto
@@ -1,68 +0,0 @@
1
- package user
2
-
3
- import (
4
- "fmt"
5
- "net/http"
6
-
7
- "{{goModule}}/internal/shared/apperror"
8
- )
9
-
10
- // functions, not vars: the error middleware writes RequestID onto the
11
- // returned pointer directly — a shared instance would race across
12
- // concurrent requests.
13
-
14
- func errNotFound() *apperror.AppError {
15
- return apperror.New(http.StatusNotFound, "USER_NOT_FOUND", "user not found")
16
- }
17
-
18
- func errEmailTaken() *apperror.AppError {
19
- return apperror.New(http.StatusConflict, "USER_EMAIL_TAKEN", "email already registered")
20
- }
21
-
22
- // errInvalidCredentials is returned for both "no such user" and "wrong
23
- // password" — a single generic message so a login attempt can't be used to
24
- // enumerate which emails have accounts.
25
- // errTooManyAttempts is the account-level lockout, distinct from the per-IP
26
- // RATE_LIMITED the middleware returns: this one follows the account wherever
27
- // the attempts come from.
28
- func errTooManyAttempts() *apperror.AppError {
29
- return apperror.New(http.StatusTooManyRequests, "AUTH_TOO_MANY_ATTEMPTS", "too many failed attempts — try again later")
30
- }
31
-
32
- func errInvalidCredentials() *apperror.AppError {
33
- return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_CREDENTIALS", "invalid email or password")
34
- }
35
-
36
- func errInvalidToken() *apperror.AppError {
37
- return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_TOKEN", "invalid or expired refresh token")
38
- }
39
-
40
- func errAlreadyVerified() *apperror.AppError {
41
- return apperror.New(http.StatusConflict, "AUTH_ALREADY_VERIFIED", "email is already verified")
42
- }
43
-
44
- func errMFAUnavailable() *apperror.AppError {
45
- return apperror.New(http.StatusServiceUnavailable, "AUTH_MFA_UNAVAILABLE", "multi-factor authentication is not enabled")
46
- }
47
-
48
- func errMFAInvalid() *apperror.AppError {
49
- return apperror.New(http.StatusUnauthorized, "AUTH_MFA_INVALID", "invalid or expired multi-factor authentication code")
50
- }
51
-
52
- func errMFAAlreadyEnabled() *apperror.AppError {
53
- return apperror.New(http.StatusConflict, "AUTH_MFA_ALREADY_ENABLED", "multi-factor authentication is already enabled")
54
- }
55
-
56
- func errMFANotEnrolled() *apperror.AppError {
57
- return apperror.New(http.StatusConflict, "AUTH_MFA_NOT_ENROLLED", "multi-factor authentication is not enabled for this user")
58
- }
59
-
60
- func errMFASetupRequired() *apperror.AppError {
61
- return apperror.New(http.StatusConflict, "AUTH_MFA_SETUP_REQUIRED", "complete multi-factor authentication setup first")
62
- }
63
-
64
- func errMFAConfig() *apperror.AppError {
65
- return apperror.NewInternal(fmt.Errorf("multi-factor authentication encryption key is invalid"))
66
- }
67
-
68
- // go-scaffold:user-errors
@@ -1,45 +0,0 @@
1
- package model
2
-
3
- import (
4
- "time"
5
-
6
- "github.com/google/uuid"
7
- )
8
-
9
- // AuthToken is one short-lived token — the Postgres equivalent of the keys the
10
- // Redis store would hold. Only the SHA-256 hash of the raw token is stored, so
11
- // a database dump never yields a usable token.
12
- //
13
- // `Kind` mirrors the Redis key prefixes one-for-one, deliberately:
14
- //
15
- // refresh an active refresh token
16
- // refresh_used a tombstone left by rotation, so replaying a rotated-out
17
- // token is detectable as reuse rather than merely unknown
18
- // pwreset password reset, consumed once
19
- // emailverify email verification, consumed once
20
- //
21
- // Keeping the tombstone a separate row (rather than a used_at column on the
22
- // refresh row) is what preserves logout's semantics: logout deletes the row
23
- // outright and leaves no tombstone, so someone replaying a logged-out token
24
- // gets a plain rejection instead of tripping reuse detection and nuking every
25
- // session the user has.
26
- //
27
- // No foreign key to users on purpose: the development bootstrap wouldn't
28
- // create one from these tags, and a constraint that exists in production but not in
29
- // development is the exact mismatch that stops the app booting. A token whose
30
- // user is gone simply fails the lookup that follows.
31
- type AuthToken struct {
32
- TokenHash string `gorm:"primaryKey;type:text"`
33
- UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_auth_tokens_user_kind,priority:1"`
34
- Kind string `gorm:"type:varchar(20);not null;index:idx_auth_tokens_user_kind,priority:2"`
35
- ExpiresAt time.Time `gorm:"not null;index:idx_auth_tokens_expires_at"`
36
- // AbsoluteExpiresAt is set only for refresh tokens. Recovery tokens and
37
- // OAuth transactions have no sliding lifetime, so it remains nil there.
38
- AbsoluteExpiresAt *time.Time `gorm:"index:idx_auth_tokens_absolute_expires_at"`
39
- Provider string `gorm:"type:varchar(20);not null;default:''"`
40
- CodeChallenge string `gorm:"type:text;not null;default:''"`
41
- Nonce string `gorm:"type:text;not null;default:''"`
42
- CreatedAt time.Time
43
- }
44
-
45
- func (AuthToken) TableName() string { return "user_svc.auth_tokens" }
@@ -1,32 +0,0 @@
1
- package model
2
-
3
- import (
4
- "time"
5
-
6
- "github.com/google/uuid"
7
- )
8
-
9
- type Provider string
10
-
11
- const (
12
- ProviderLocal Provider = "local"
13
- ProviderGoogle Provider = "google"
14
- )
15
-
16
- // Identity = how you log in (one row per login method). PasswordHash is set
17
- // for a "local" identity; ProviderUID is set for an external provider (the
18
- // provider's own stable subject id). Split from User so adding a login method
19
- // never touches the profile row — one person can have several methods and
20
- // resolve to the same account.
21
- type Identity struct {
22
- ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
23
- UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null;uniqueIndex:idx_identities_user_provider,priority:1"`
24
- Provider Provider `json:"provider" gorm:"type:varchar(20);not null;uniqueIndex:idx_identities_user_provider,priority:2;uniqueIndex:idx_identities_provider_uid,priority:1"`
25
- PasswordHash *string `json:"-"`
26
- ProviderUID *string `json:"-" gorm:"column:provider_uid;uniqueIndex:idx_identities_provider_uid,priority:2"`
27
- CreatedAt time.Time `json:"created_at"`
28
- UpdatedAt time.Time `json:"updated_at"`
29
- }
30
-
31
- // TableName — see User.TableName; same schema, same reasoning.
32
- func (Identity) TableName() string { return "user_svc.identities" }
@@ -1,26 +0,0 @@
1
- package model
2
-
3
- import "time"
4
-
5
- // LoginThrottle is the failed-attempt counter that makes lockout survive a
6
- // deploy and mean the same thing on every replica. It is not the rate limiter
7
- // — that one counts requests per IP and is allowed to be approximate. This
8
- // counts failures per account, and per OWASP that is the control that actually
9
- // stops credential stuffing: an attacker with a proxy pool keeps per-IP volume
10
- // under any threshold you set, but they cannot spread attempts against one
11
- // account across accounts.
12
- //
13
- // EmailHash, not the address: the counter has to be keyed on what the caller
14
- // typed whether or not an account exists — otherwise "did this get throttled"
15
- // answers "does this account exist" — and hashing means this table never
16
- // becomes a directory of who has signed up.
17
- type LoginThrottle struct {
18
- EmailHash string `gorm:"primaryKey;type:text"`
19
- Failures int `gorm:"not null;default:0"`
20
- // nil until the free attempts are spent; afterwards it moves further out
21
- // with each failure.
22
- LockedUntil *time.Time
23
- UpdatedAt time.Time
24
- }
25
-
26
- func (LoginThrottle) TableName() string { return "user_svc.login_throttle" }
@@ -1,17 +0,0 @@
1
- package model
2
-
3
- import (
4
- "time"
5
-
6
- "github.com/google/uuid"
7
- )
8
-
9
- // MFAChallenge is a one-use pre-session challenge. Only its hash is stored.
10
- type MFAChallenge struct {
11
- ChallengeHash string `gorm:"type:text;primaryKey"`
12
- UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_mfa_challenges_user"`
13
- ExpiresAt time.Time `gorm:"not null;index:idx_mfa_challenges_expires_at"`
14
- CreatedAt time.Time
15
- }
16
-
17
- func (MFAChallenge) TableName() string { return "user_svc.mfa_challenges" }
@@ -1,20 +0,0 @@
1
- package model
2
-
3
- import (
4
- "time"
5
-
6
- "github.com/google/uuid"
7
- )
8
-
9
- // MFAEnrollment stores one user's TOTP enrollment. The secret is encrypted by
10
- // the application before it reaches this model; plaintext secrets never live
11
- // in the database.
12
- type MFAEnrollment struct {
13
- UserID uuid.UUID `gorm:"type:uuid;primaryKey"`
14
- EncryptedSecret string `gorm:"type:text;not null"`
15
- Enabled bool `gorm:"not null;default:false"`
16
- CreatedAt time.Time
17
- UpdatedAt time.Time
18
- }
19
-
20
- func (MFAEnrollment) TableName() string { return "user_svc.mfa_enrollments" }
@@ -1,17 +0,0 @@
1
- package model
2
-
3
- import (
4
- "time"
5
-
6
- "github.com/google/uuid"
7
- )
8
-
9
- // MFARecoveryCode stores a hash only. A consumed code is deleted atomically,
10
- // so the table never becomes a second mutable session state machine.
11
- type MFARecoveryCode struct {
12
- UserID uuid.UUID `gorm:"type:uuid;primaryKey;index:idx_mfa_recovery_codes_user"`
13
- CodeHash string `gorm:"type:text;primaryKey"`
14
- CreatedAt time.Time
15
- }
16
-
17
- func (MFARecoveryCode) TableName() string { return "user_svc.mfa_recovery_codes" }
@@ -1,31 +0,0 @@
1
- // Package model holds this domain's GORM tables.
2
- package model
3
-
4
- import (
5
- "time"
6
-
7
- "github.com/google/uuid"
8
- )
9
-
10
- // User = who you are (profile). How you log in is a separate concept — see
11
- // Identity — so the same person can have both a password and a Google login
12
- // resolving to one account.
13
- type User struct {
14
- ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
15
- // index named explicitly, and named the same in create_users.up.sql: an
16
- // anonymous `uniqueIndex` makes GORM invent one, and the development
17
- // bootstrap then tries to DROP the differently-named constraint the
18
- // migration created.
19
- Email string `json:"email" gorm:"uniqueIndex:idx_users_email;not null"`
20
- Name string `json:"name"`
21
- AvatarURL string `json:"avatar_url"`
22
- EmailVerified bool `json:"email_verified"`
23
- // go-scaffold:user-fields
24
- CreatedAt time.Time `json:"created_at"`
25
- UpdatedAt time.Time `json:"updated_at"`
26
- }
27
-
28
- // TableName pins this to user_svc rather than GORM's default inflection
29
- // ("users", schema-less) — every domain gets its own schema, see
30
- // docs/architect/patterns.md.
31
- func (User) TableName() string { return "user_svc.users" }
@@ -1,139 +0,0 @@
1
- package user
2
-
3
- import (
4
- "context"
5
- "errors"
6
- "time"
7
-
8
- "{{goModule}}/internal/app/user/model"
9
- "{{goModule}}/internal/shared/tx"
10
-
11
- "github.com/google/uuid"
12
- "gorm.io/gorm"
13
- )
14
-
15
- type Repository struct {
16
- db *gorm.DB
17
- }
18
-
19
- func NewRepository(db *gorm.DB) *Repository {
20
- return &Repository{db: db}
21
- }
22
-
23
- func (r *Repository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
24
- var u model.User
25
- if err := tx.From(ctx, r.db).WithContext(ctx).First(&u, "email = ?", email).Error; err != nil {
26
- return nil, err
27
- }
28
- return &u, nil
29
- }
30
-
31
- func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*model.User, error) {
32
- var u model.User
33
- if err := tx.From(ctx, r.db).WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
34
- return nil, err
35
- }
36
- return &u, nil
37
- }
38
-
39
- func (r *Repository) UpdateUser(ctx context.Context, u *model.User) error {
40
- return tx.From(ctx, r.db).WithContext(ctx).Save(u).Error
41
- }
42
-
43
- func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.User, error) {
44
- var items []model.User
45
- err := tx.From(ctx, r.db).WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error
46
- return items, err
47
- }
48
-
49
- func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider model.Provider) (*model.Identity, error) {
50
- var i model.Identity
51
- if err := tx.From(ctx, r.db).WithContext(ctx).First(&i, "user_id = ? AND provider = ?", userID, provider).Error; err != nil {
52
- return nil, err
53
- }
54
- return &i, nil
55
- }
56
-
57
- func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider model.Provider, providerUID string) (*model.Identity, error) {
58
- var i model.Identity
59
- if err := tx.From(ctx, r.db).WithContext(ctx).First(&i, "provider = ? AND provider_uid = ?", provider, providerUID).Error; err != nil {
60
- return nil, err
61
- }
62
- return &i, nil
63
- }
64
-
65
- // CreateIdentity links a new login method onto an EXISTING user (e.g.
66
- // Google linking onto an account that registered with a password first) —
67
- // see CreateUserWithIdentity for the "brand new user" case.
68
- func (r *Repository) CreateIdentity(ctx context.Context, i *model.Identity) error {
69
- return tx.From(ctx, r.db).WithContext(ctx).Create(i).Error
70
- }
71
-
72
- func (r *Repository) UpdateIdentity(ctx context.Context, i *model.Identity) error {
73
- return tx.From(ctx, r.db).WithContext(ctx).Save(i).Error
74
- }
75
-
76
- // CreateUserWithIdentity inserts the profile and its first login method in
77
- // one transaction — a user with no identity at all can't log in any way, so
78
- // the two rows always exist together or not at all.
79
- func (r *Repository) CreateUserWithIdentity(ctx context.Context, u *model.User, i *model.Identity) error {
80
- return tx.From(ctx, r.db).WithContext(ctx).Transaction(func(t *gorm.DB) error {
81
- if err := t.Create(u).Error; err != nil {
82
- return err
83
- }
84
- i.UserID = u.ID
85
- return t.Create(i).Error
86
- })
87
- }
88
-
89
- // --- failed-login throttle -------------------------------------------------
90
- //
91
- // Keyed on a caller-supplied hash rather than a user id, because the counter
92
- // has to work for addresses that have no account — see model.LoginThrottle.
93
-
94
- // LoginLockedUntil reports when this key stops being locked out — zero time
95
- // when it isn't. Read on every login attempt, so it stays a primary-key hit.
96
- func (r *Repository) LoginLockedUntil(ctx context.Context, key string) (time.Time, error) {
97
- var row model.LoginThrottle
98
- err := tx.From(ctx, r.db).WithContext(ctx).Where("email_hash = ?", key).Take(&row).Error
99
- if errors.Is(err, gorm.ErrRecordNotFound) {
100
- return time.Time{}, nil
101
- }
102
- if err != nil || row.LockedUntil == nil {
103
- return time.Time{}, err
104
- }
105
- return *row.LockedUntil, nil
106
- }
107
-
108
- // RecordLoginFailure bumps the counter and pushes the lock further out,
109
- // doubling each time past freeAttempts and never exceeding maxLock.
110
- //
111
- // One statement, so two attempts racing cannot both read the same count and
112
- // write the same lock — which would let an attacker keep the backoff pinned at
113
- // its first step by running attempts in parallel. Computing the interval in
114
- // SQL is what buys that; a read-then-write in Go would need a transaction and
115
- // a row lock to be equally safe.
116
- func (r *Repository) RecordLoginFailure(ctx context.Context, key string, freeAttempts int, maxLock time.Duration) error {
117
- return tx.From(ctx, r.db).WithContext(ctx).Exec(`
118
- INSERT INTO user_svc.login_throttle AS t (email_hash, failures, locked_until, updated_at)
119
- VALUES (?, 1, NULL, now())
120
- ON CONFLICT (email_hash) DO UPDATE SET
121
- failures = t.failures + 1,
122
- locked_until = CASE
123
- WHEN t.failures + 1 <= ? THEN NULL
124
- ELSE now() + make_interval(secs => least(power(2, t.failures + 1 - ?), ?))
125
- END,
126
- updated_at = now()`,
127
- key, freeAttempts, freeAttempts, maxLock.Seconds()).Error
128
- }
129
-
130
- // ClearLoginFailures drops the row on a successful login, so someone who
131
- // mistypes twice and then gets in starts clean rather than creeping toward a
132
- // lockout over weeks.
133
- func (r *Repository) ClearLoginFailures(ctx context.Context, key string) error {
134
- return tx.From(ctx, r.db).WithContext(ctx).
135
- Where("email_hash = ?", key).
136
- Delete(&model.LoginThrottle{}).Error
137
- }
138
-
139
- // go-scaffold:user-repository-methods
@@ -1,135 +0,0 @@
1
- package user
2
-
3
- import (
4
- "context"
5
- "strings"
6
- "time"
7
-
8
- "{{goModule}}/internal/app/user/application"
9
- "{{goModule}}/internal/app/user/model"
10
-
11
- "github.com/google/uuid"
12
- )
13
-
14
- // repository = what the user application needs from the data layer. The
15
- // interface stays consumer-owned so every use case can be tested without a
16
- // database, while the concrete GORM implementation remains in repository.go.
17
- type UserRepository interface {
18
- FindByEmail(ctx context.Context, email string) (*model.User, error)
19
- FindByID(ctx context.Context, id uuid.UUID) (*model.User, error)
20
- UpdateUser(ctx context.Context, u *model.User) error
21
- FindAll(ctx context.Context, limit, offset int) ([]model.User, error)
22
- FindIdentity(ctx context.Context, userID uuid.UUID, provider model.Provider) (*model.Identity, error)
23
- FindIdentityByProviderUID(ctx context.Context, provider model.Provider, providerUID string) (*model.Identity, error)
24
- CreateUserWithIdentity(ctx context.Context, u *model.User, i *model.Identity) error
25
- CreateIdentity(ctx context.Context, i *model.Identity) error
26
- UpdateIdentity(ctx context.Context, i *model.Identity) error
27
- LoginLockedUntil(ctx context.Context, key string) (time.Time, error)
28
- RecordLoginFailure(ctx context.Context, key string, freeAttempts int, maxLock time.Duration) error
29
- ClearLoginFailures(ctx context.Context, key string) error
30
- // go-scaffold:user-repository-interface
31
- }
32
-
33
- // repository is kept as a package-local alias so generated method extensions
34
- // and focused tests can use the short consumer-side name without hiding the
35
- // public Dependencies contract from callers outside this package.
36
- type repository = UserRepository
37
-
38
- // mailer = what the service needs to send an email. The concrete client may
39
- // send inline or enqueue onto cmd/worker; auth only depends on this port.
40
- type AuthMailer interface {
41
- Send(ctx context.Context, to, subject, body string) error
42
- }
43
-
44
- // go-scaffold:user-interfaces
45
-
46
- // RoleChecker is the narrow capability the user domain needs when RBAC is
47
- // installed. Keeping the interface here means auth can be generated on its
48
- // own; the role domain satisfies it structurally when the optional feature is
49
- // added.
50
- type RoleChecker interface {
51
- CodeExists(ctx context.Context, code string) (bool, error)
52
- }
53
-
54
- // Service is the stable facade exposed to handlers, cmd/seed, and generated
55
- // extensions. The behavior is implemented in focused files by use-case area:
56
- // local_auth.go, sessions.go, recovery_service.go, external_login.go, and
57
- // user_query.go. Keeping the facade avoids route and generator compatibility
58
- // churn while preventing one 600-line service file from becoming the place
59
- // where every auth concern must be edited.
60
- type Service struct {
61
- repo UserRepository
62
- refreshTokens RefreshTokenStore
63
- oauthTransactions OAuthTransactionStore
64
- recoveryTokens RecoveryTokenStore
65
- mfa MFAStore
66
- mailer AuthMailer
67
- providers ProviderRegistry
68
- recovery *application.Recovery
69
- config AuthConfig
70
- roles RoleChecker
71
- now func() time.Time
72
- // go-scaffold:user-service-fields
73
- }
74
-
75
- // NewService is deliberately explicit: infrastructure dependencies and auth
76
- // policy are separate values, so the application layer never receives the
77
- // generated project's catch-all shared configuration object.
78
- func NewService(deps Dependencies, cfg AuthConfig) *Service {
79
- if cfg.JWTAccessTTL <= 0 {
80
- cfg.JWTAccessTTL = 15 * time.Minute
81
- }
82
- if cfg.JWTRefreshTTL <= 0 {
83
- cfg.JWTRefreshTTL = 12 * time.Hour
84
- }
85
- cfg.JWTRefreshMaxTTL = defaultDuration(cfg.JWTRefreshMaxTTL, cfg.JWTRefreshTTL)
86
- cfg.OAuthStateTTL = defaultDuration(cfg.OAuthStateTTL, 10*time.Minute)
87
- if cfg.MFA.RecoveryCodeCount == 0 {
88
- cfg.MFA.RecoveryCodeCount = defaultMFACodes
89
- }
90
- clock := deps.Clock
91
- if clock == nil {
92
- clock = time.Now
93
- }
94
- providers := deps.Providers
95
- if providers == nil {
96
- providers = application.NewProviderRegistry()
97
- }
98
- return &Service{
99
- repo: deps.Repository,
100
- refreshTokens: deps.RefreshTokens,
101
- oauthTransactions: deps.OAuthTransactions,
102
- recoveryTokens: deps.RecoveryTokens,
103
- mfa: deps.MFA,
104
- mailer: deps.Mailer,
105
- providers: providers,
106
- recovery: newRecovery(deps.Repository, deps.RecoveryTokens),
107
- config: cfg,
108
- roles: deps.Roles,
109
- now: clock,
110
- // go-scaffold:user-service-init
111
- }
112
- }
113
-
114
- func defaultDuration(value, fallback time.Duration) time.Duration {
115
- if value > 0 {
116
- return value
117
- }
118
- return fallback
119
- }
120
-
121
- func (s *Service) clock() time.Time {
122
- if s.now == nil {
123
- return time.Now()
124
- }
125
- return s.now()
126
- }
127
-
128
- // normalizeEmail is applied at every boundary an address enters the service
129
- // through. The column is a plain case-sensitive UNIQUE, so without this
130
- // "Foo@x.com" and "foo@x.com" could become two accounts.
131
- func normalizeEmail(email string) string {
132
- return strings.ToLower(strings.TrimSpace(email))
133
- }
134
-
135
- // go-scaffold:user-service-methods