@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,89 +1,33 @@
1
1
  package user
2
2
 
3
3
  import (
4
- "context"
5
4
  "fmt"
6
5
  "time"
7
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"
8
10
  "{{goModule}}/internal/app/user/application"
9
- "{{goModule}}/internal/app/user/model"
11
+ "{{goModule}}/internal/app/user/ports"
10
12
  googleprovider "{{goModule}}/internal/platform/authprovider/google"
11
13
  "{{goModule}}/internal/platform/mail"
14
+ {{#if worker}}
15
+ "{{goModule}}/internal/platform/queue"
16
+ {{/if}}
12
17
  "{{goModule}}/internal/shared/config"
13
18
  "{{goModule}}/internal/shared/middleware"
14
19
 
15
- "github.com/google/uuid"
16
- "golang.org/x/crypto/bcrypt"
17
- {{#if redis}}
20
+ {{#if redis}}
21
+ userredis "{{goModule}}/internal/app/user/adapters/outbound/redis"
18
22
  "github.com/redis/go-redis/v9"
19
- {{/if}}
20
- {{#if worker}}
21
- "{{goModule}}/internal/platform/queue"
22
- {{/if}}
23
+ {{/if}}
24
+ "github.com/gin-gonic/gin"
23
25
  "gorm.io/gorm"
24
26
  )
25
27
 
26
- // recoveryRepositoryAdapter maps persistence models to the plain values used
27
- // by the application recovery use case. The adapter is the only layer that
28
- // knows both the GORM model and the application port.
29
- type recoveryRepositoryAdapter struct {
30
- repo repository
31
- }
32
-
33
- func (a recoveryRepositoryAdapter) FindIdentity(ctx context.Context, userID uuid.UUID) (*application.Identity, error) {
34
- identity, err := a.repo.FindIdentity(ctx, userID, model.ProviderLocal)
35
- if err != nil {
36
- return nil, err
37
- }
38
- if identity.PasswordHash == nil {
39
- return nil, fmt.Errorf("local identity has no password")
40
- }
41
- return &application.Identity{ID: identity.ID, UserID: identity.UserID, PasswordHash: *identity.PasswordHash}, nil
42
- }
43
-
44
- func (a recoveryRepositoryAdapter) UpdateIdentity(ctx context.Context, identity *application.Identity) error {
45
- current, err := a.repo.FindIdentity(ctx, identity.UserID, model.ProviderLocal)
46
- if err != nil {
47
- return err
48
- }
49
- current.PasswordHash = &identity.PasswordHash
50
- return a.repo.UpdateIdentity(ctx, current)
51
- }
52
-
53
- func (a recoveryRepositoryAdapter) FindUser(ctx context.Context, userID uuid.UUID) (*application.User, error) {
54
- user, err := a.repo.FindByID(ctx, userID)
55
- if err != nil {
56
- return nil, err
57
- }
58
- return &application.User{ID: user.ID, EmailVerified: user.EmailVerified}, nil
59
- }
60
-
61
- func (a recoveryRepositoryAdapter) UpdateUser(ctx context.Context, user *application.User) error {
62
- current, err := a.repo.FindByID(ctx, user.ID)
63
- if err != nil {
64
- return err
65
- }
66
- current.EmailVerified = user.EmailVerified
67
- return a.repo.UpdateUser(ctx, current)
68
- }
69
-
70
- type bcryptPasswordHasher struct{}
71
-
72
- func (bcryptPasswordHasher) Hash(password string) (string, error) {
73
- hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
74
- if err != nil {
75
- return "", err
76
- }
77
- return string(hash), nil
78
- }
79
-
80
- func newRecovery(repo repository, tokens application.RecoveryTokens) *application.Recovery {
81
- return application.NewRecovery(recoveryRepositoryAdapter{repo: repo}, tokens, bcryptPasswordHasher{})
82
- }
83
-
84
- // NewHandlerFromDB is auth's local composition root. cmd/api owns shared
85
- // infrastructure and cross-feature dependencies; repository, token store,
86
- // mailer, service, limiter, and handler construction stays in this package.
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.
87
31
  func NewHandlerFromDB(
88
32
  db *gorm.DB,
89
33
  cfg config.Config,
@@ -92,23 +36,23 @@ func NewHandlerFromDB(
92
36
  {{/if}}
93
37
  {{#if worker}}
94
38
  q queue.Enqueuer,
95
- {{/if}}
39
+ {{/if}}
96
40
  roleChecker RoleChecker,
97
- authz authorizer,
98
- ) *Handler {
41
+ authz Authorizer,
42
+ ) *httpadapter.Handler {
99
43
  {{#if redis}}
100
- tokens := NewRedisTokenStore(rdb, db)
44
+ tokens := userredis.NewRedisTokenStore(rdb, db)
101
45
  {{else}}
102
- tokens := NewPgTokenStore(db)
46
+ tokens := userpostgres.NewPgTokenStore(db)
103
47
  {{/if}}
104
48
 
105
49
  {{#if worker}}
106
- outbound := mail.NewAsyncClient(q)
50
+ mailer := mail.NewAsyncClient(q)
107
51
  {{else}}
108
- outbound := mail.NewSyncClient(mail.Open(cfg))
52
+ mailer := mail.NewSyncClient(mail.Open(cfg))
109
53
  {{/if}}
110
54
 
111
- var providers []application.LoginProvider
55
+ providers := make([]application.LoginProvider, 0, 1)
112
56
  if cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "" && cfg.GoogleOAuthRedirectURI != "" {
113
57
  providers = append(providers, googleprovider.New(googleprovider.Config{
114
58
  ClientID: cfg.GoogleClientID,
@@ -117,7 +61,7 @@ outbound := mail.NewSyncClient(mail.Open(cfg))
117
61
  }))
118
62
  }
119
63
  providerRegistry := application.NewProviderRegistry(providers...)
120
- if err := validateBrowserCookiePolicy(cfg.AuthBrowserTopology, cfg.CookieSameSite, cfg.CookieSecure, cfg.IsProd()); err != nil {
64
+ if err := httpadapter.ValidateBrowserCookiePolicy(cfg.AuthBrowserTopology, cfg.CookieSameSite, cfg.CookieSecure, cfg.IsProd()); err != nil {
121
65
  panic(err)
122
66
  }
123
67
  if cfg.JWTRefreshTTL <= 0 || cfg.JWTRefreshMaxTTL <= 0 {
@@ -127,17 +71,18 @@ outbound := mail.NewSyncClient(mail.Open(cfg))
127
71
  panic(fmt.Errorf("OAUTH_STATE_TTL_MIN must be positive"))
128
72
  }
129
73
 
130
- service := NewService(Dependencies{
131
- Repository: NewRepository(db),
74
+ service := application.NewService(application.Dependencies{
75
+ Repository: userpostgres.NewRepository(db),
76
+ Passwords: userpassword.NewBCryptHasher(),
132
77
  RefreshTokens: tokens,
133
78
  OAuthTransactions: tokens,
134
79
  RecoveryTokens: tokens,
135
- MFA: NewPostgresMFAStore(db),
136
- Mailer: outbound,
80
+ MFA: userpostgres.NewPostgresMFAStore(db),
81
+ Mailer: mailer,
137
82
  Providers: providerRegistry,
138
83
  Roles: roleChecker,
139
84
  Clock: time.Now,
140
- }, AuthConfig{
85
+ }, application.AuthConfig{
141
86
  JWTSecret: cfg.JWTSecret,
142
87
  JWTAccessTTL: cfg.JWTAccessTTL,
143
88
  JWTRefreshTTL: cfg.JWTRefreshTTL,
@@ -147,7 +92,7 @@ outbound := mail.NewSyncClient(mail.Open(cfg))
147
92
  PasswordResetURL: cfg.PasswordResetURL,
148
93
  EmailVerifyTTL: cfg.EmailVerifyTTL,
149
94
  EmailVerifyURL: cfg.EmailVerifyURL,
150
- MFA: MFASettings{
95
+ MFA: application.MFASettings{
151
96
  Enabled: cfg.AuthMFAEnabled,
152
97
  Issuer: cfg.MFAIssuer,
153
98
  EncryptionKey: cfg.MFAEncryptionKey,
@@ -161,5 +106,63 @@ outbound := mail.NewSyncClient(mail.Open(cfg))
161
106
  {{else}}
162
107
  limiter := middleware.NewMemoryLimiter()
163
108
  {{/if}}
164
- return NewHandlerWithOrigins(service, cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, cfg.CookieSameSite, cfg.CORSAllowedOrigins, limiter, authz)
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)
165
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
+ }
@@ -1,6 +1,6 @@
1
1
  -- Durable recovery tokens for both refresh-store choices. Refresh rotation may
2
2
  -- use Redis, but password reset and email verification share this DB contract.
3
- -- Only the SHA-256 hash is stored; see model/authtoken.go for what `kind`
3
+ -- Only the SHA-256 hash is stored; see adapters/outbound/postgres/model.go for what `kind`
4
4
  -- means and why the rotation tombstone is a separate row.
5
5
  CREATE TABLE user_svc.auth_tokens (
6
6
  token_hash TEXT PRIMARY KEY,
@@ -1,7 +1,7 @@
1
1
  -- Failed-attempt counter per account. Keyed on a hash of the submitted email
2
2
  -- so it works for addresses that have no account (otherwise throttling itself
3
3
  -- leaks which addresses are registered) and so this table is not a user list.
4
- -- See model/loginthrottle.go for why this exists separately from the rate limiter.
4
+ -- See adapters/outbound/postgres/model.go for why this exists separately from the rate limiter.
5
5
  CREATE TABLE user_svc.login_throttle (
6
6
  email_hash TEXT PRIMARY KEY,
7
7
  failures INTEGER NOT NULL DEFAULT 0,
@@ -6,12 +6,13 @@ CREATE TABLE user_svc.users (
6
6
  name VARCHAR(255) NOT NULL DEFAULT '',
7
7
  avatar_url TEXT NOT NULL DEFAULT '',
8
8
  email_verified BOOLEAN NOT NULL DEFAULT false,
9
+ role VARCHAR(20) NOT NULL DEFAULT 'staff',
9
10
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
10
11
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
11
12
  );
12
13
 
13
14
  -- A unique INDEX, not an inline UNIQUE constraint, and named to match the
14
- -- `uniqueIndex:idx_users_email` tag on model.User exactly. Postgres names an
15
+ -- `uniqueIndex:idx_users_email` tag on the outbound persistence model exactly. Postgres names an
15
16
  -- inline UNIQUE "users_email_key"; the development bootstrap then sees
16
17
  -- uniqueness it didn't create and tries to drop it under its own
17
18
  -- name, which fails the boot with
@@ -0,0 +1,142 @@
1
+ // Package httpadapter is role's inbound HTTP boundary. It owns route,
2
+ // authentication middleware, request binding, response DTOs, and translation
3
+ // from application errors to the API error envelope.
4
+ package httpadapter
5
+
6
+ import (
7
+ "errors"
8
+ "net/http"
9
+
10
+ "{{goModule}}/internal/app/role/application"
11
+ "{{goModule}}/internal/app/role/domain"
12
+ "{{goModule}}/internal/shared/apperror"
13
+ "{{goModule}}/internal/shared/httpx"
14
+ "{{goModule}}/internal/shared/middleware"
15
+ "{{goModule}}/internal/shared/pagination"
16
+
17
+ "github.com/gin-gonic/gin"
18
+ )
19
+
20
+ type Handler struct {
21
+ svc application.ServicePort
22
+ jwtSecret string
23
+ authz *middleware.Authz
24
+ }
25
+
26
+ func NewHandler(svc application.ServicePort, jwtSecret string, authz *middleware.Authz) *Handler {
27
+ return &Handler{svc: svc, jwtSecret: jwtSecret, authz: authz}
28
+ }
29
+
30
+ func (h *Handler) Register(rg gin.IRouter) {
31
+ roles := rg.Group("/roles", middleware.RequireAuth(h.jwtSecret), h.authz.Require(application.PermRoleManage))
32
+ roles.GET("", h.list)
33
+ roles.POST("", h.create)
34
+ roles.PATCH("/:code/permissions", h.setPermissions)
35
+ roles.DELETE("/:code", h.delete)
36
+
37
+ perms := rg.Group("/permissions", middleware.RequireAuth(h.jwtSecret), h.authz.Require(application.PermRoleManage))
38
+ perms.GET("", h.listPermissions)
39
+ }
40
+
41
+ func (h *Handler) list(c *gin.Context) {
42
+ p := pagination.Parse(c)
43
+ items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
44
+ if err != nil {
45
+ c.Error(toHTTPError(err))
46
+ return
47
+ }
48
+ out := make([]application.RoleResponse, len(items))
49
+ for i := range items {
50
+ out[i] = application.ToRoleResponse(items[i])
51
+ }
52
+ c.JSON(http.StatusOK, p.Response(out))
53
+ }
54
+
55
+ func (h *Handler) create(c *gin.Context) {
56
+ var in application.CreateInput
57
+ if err := c.ShouldBindJSON(&in); err != nil {
58
+ c.Error(httpx.BindErr(err))
59
+ return
60
+ }
61
+ item, err := h.svc.Create(c.Request.Context(), in)
62
+ if err != nil {
63
+ c.Error(toHTTPError(err))
64
+ return
65
+ }
66
+ c.JSON(http.StatusCreated, application.ToRoleResponse(*item))
67
+ }
68
+
69
+ func (h *Handler) setPermissions(c *gin.Context) {
70
+ var in application.SetPermissionsInput
71
+ if err := c.ShouldBindJSON(&in); err != nil {
72
+ c.Error(httpx.BindErr(err))
73
+ return
74
+ }
75
+ if in.PermissionCodes == nil {
76
+ c.Error(apperror.NewValidation("invalid role input", map[string]string{"permission_codes": "is required"}))
77
+ return
78
+ }
79
+ item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), *in.PermissionCodes)
80
+ if err != nil {
81
+ c.Error(toHTTPError(err))
82
+ return
83
+ }
84
+ h.authz.Invalidate(item.Role.Code)
85
+ c.JSON(http.StatusOK, application.ToRoleResponse(*item))
86
+ }
87
+
88
+ func (h *Handler) delete(c *gin.Context) {
89
+ if err := h.svc.Delete(c.Request.Context(), c.Param("code")); err != nil {
90
+ c.Error(toHTTPError(err))
91
+ return
92
+ }
93
+ h.authz.Invalidate(c.Param("code"))
94
+ c.Status(http.StatusNoContent)
95
+ }
96
+
97
+ func (h *Handler) listPermissions(c *gin.Context) {
98
+ p := pagination.Parse(c)
99
+ items, err := h.svc.ListPermissions(c.Request.Context(), p.Limit, p.Offset)
100
+ if err != nil {
101
+ c.Error(toHTTPError(err))
102
+ return
103
+ }
104
+ out := make([]application.PermissionResponse, len(items))
105
+ for i := range items {
106
+ out[i] = application.ToPermissionResponse(items[i])
107
+ }
108
+ c.JSON(http.StatusOK, p.Response(out))
109
+ }
110
+
111
+ func toHTTPError(err error) error {
112
+ if err == nil {
113
+ return nil
114
+ }
115
+ var appErr *apperror.AppError
116
+ if errors.As(err, &appErr) {
117
+ return err
118
+ }
119
+ var rule *domain.RuleError
120
+ if errors.As(err, &rule) {
121
+ status := http.StatusUnprocessableEntity
122
+ switch {
123
+ case errors.Is(err, domain.ErrNotFound):
124
+ status = http.StatusNotFound
125
+ case errors.Is(err, domain.ErrConflict), errors.Is(err, domain.ErrHasReferences), errors.Is(err, domain.ErrSystemRole), errors.Is(err, domain.ErrLastRoleManager):
126
+ status = http.StatusConflict
127
+ }
128
+ return apperror.New(status, rule.Code, rule.Message)
129
+ }
130
+ switch {
131
+ case errors.Is(err, domain.ErrNotFound):
132
+ return apperror.New(http.StatusNotFound, "ROLE_NOT_FOUND", "role not found")
133
+ case errors.Is(err, domain.ErrConflict):
134
+ return apperror.New(http.StatusConflict, "ROLE_CONFLICT", "role conflict")
135
+ case errors.Is(err, domain.ErrHasReferences):
136
+ return apperror.New(http.StatusConflict, "ROLE_HAS_REFERENCES", "role still has users assigned to it")
137
+ case errors.Is(err, domain.ErrUnknownPermission):
138
+ return apperror.New(http.StatusUnprocessableEntity, "ROLE_UNKNOWN_PERMISSION", "one or more permission codes do not exist")
139
+ default:
140
+ return apperror.NewInternal(err)
141
+ }
142
+ }
@@ -0,0 +1,19 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "context"
5
+ "testing"
6
+ "time"
7
+
8
+ "{{goModule}}/internal/shared/middleware"
9
+
10
+ "github.com/gin-gonic/gin"
11
+ )
12
+
13
+ func TestHandler_Register(t *testing.T) {
14
+ gin.SetMode(gin.TestMode)
15
+ authz := middleware.NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
16
+ return map[string]struct{}{}, nil
17
+ }, time.Minute)
18
+ NewHandler(nil, "", authz).Register(gin.New())
19
+ }
@@ -0,0 +1,48 @@
1
+ // Package postgres contains role persistence records. They are deliberately
2
+ // separate from role/domain so schema tags cannot leak into use cases.
3
+ package postgres
4
+
5
+ import (
6
+ "time"
7
+
8
+ "{{goModule}}/internal/app/role/domain"
9
+ )
10
+
11
+ type Role struct {
12
+ Code string `gorm:"type:varchar(20);primaryKey"`
13
+ Name string `gorm:"not null"`
14
+ IsSystem bool `gorm:"not null;default:false"`
15
+ CreatedAt time.Time
16
+ UpdatedAt time.Time
17
+ }
18
+
19
+ func (Role) TableName() string { return "role_svc.roles" }
20
+
21
+ type Permission struct {
22
+ Code string `gorm:"type:varchar(50);primaryKey"`
23
+ Description string `gorm:"not null"`
24
+ }
25
+
26
+ func (Permission) TableName() string { return "role_svc.permissions" }
27
+
28
+ type RolePermission struct {
29
+ RoleCode string `gorm:"primaryKey;type:varchar(20)"`
30
+ PermissionCode string `gorm:"primaryKey;type:varchar(50)"`
31
+ }
32
+
33
+ func (RolePermission) TableName() string { return "role_svc.role_permissions" }
34
+
35
+ func toDomainRole(row *Role) domain.Role {
36
+ return domain.Role{Code: row.Code, Name: row.Name, IsSystem: row.IsSystem, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
37
+ }
38
+
39
+ func fromDomainRole(value *domain.Role) *Role {
40
+ if value == nil {
41
+ return nil
42
+ }
43
+ return &Role{Code: value.Code, Name: value.Name, IsSystem: value.IsSystem, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt}
44
+ }
45
+
46
+ func toDomainPermission(row *Permission) domain.Permission {
47
+ return domain.Permission{Code: row.Code, Description: row.Description}
48
+ }