@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,4 +1,4 @@
1
- package user
1
+ package postgres
2
2
 
3
3
  import (
4
4
  "context"
@@ -7,7 +7,7 @@ import (
7
7
  "fmt"
8
8
  "time"
9
9
 
10
- "{{goModule}}/internal/app/user/model"
10
+ "{{goModule}}/internal/app/user/ports"
11
11
  "{{goModule}}/internal/shared/tx"
12
12
 
13
13
  "github.com/google/uuid"
@@ -22,25 +22,27 @@ type PostgresMFAStore struct {
22
22
  db *gorm.DB
23
23
  }
24
24
 
25
+ var _ ports.MFAStore = (*PostgresMFAStore)(nil)
26
+
25
27
  func NewPostgresMFAStore(db *gorm.DB) *PostgresMFAStore { return &PostgresMFAStore{db: db} }
26
28
 
27
- func (s *PostgresMFAStore) GetEnrollment(ctx context.Context, userID uuid.UUID) (MFAEnrollment, bool, error) {
28
- var row model.MFAEnrollment
29
+ func (s *PostgresMFAStore) GetEnrollment(ctx context.Context, userID uuid.UUID) (ports.MFAEnrollment, bool, error) {
30
+ var row MFAEnrollment
29
31
  err := tx.From(ctx, s.db).WithContext(ctx).First(&row, "user_id = ?", userID).Error
30
32
  if errors.Is(err, gorm.ErrRecordNotFound) {
31
- return MFAEnrollment{}, false, nil
33
+ return ports.MFAEnrollment{}, false, nil
32
34
  }
33
35
  if err != nil {
34
- return MFAEnrollment{}, false, err
36
+ return ports.MFAEnrollment{}, false, err
35
37
  }
36
- return MFAEnrollment{EncryptedSecret: row.EncryptedSecret, Enabled: row.Enabled}, true, nil
38
+ return ports.MFAEnrollment{EncryptedSecret: row.EncryptedSecret, Enabled: row.Enabled}, true, nil
37
39
  }
38
40
 
39
41
  func (s *PostgresMFAStore) PutPendingEnrollment(ctx context.Context, userID uuid.UUID, encryptedSecret string) error {
40
42
  if userID == uuid.Nil || encryptedSecret == "" {
41
43
  return errors.New("MFA enrollment is incomplete")
42
44
  }
43
- row := model.MFAEnrollment{UserID: userID, EncryptedSecret: encryptedSecret, Enabled: false}
45
+ row := MFAEnrollment{UserID: userID, EncryptedSecret: encryptedSecret, Enabled: false}
44
46
  return tx.From(ctx, s.db).WithContext(ctx).
45
47
  Where("user_id = ? AND enabled = ?", userID, false).
46
48
  Assign(map[string]any{"encrypted_secret": encryptedSecret, "enabled": false, "updated_at": time.Now()}).
@@ -52,7 +54,7 @@ func (s *PostgresMFAStore) ConfirmEnrollment(ctx context.Context, userID uuid.UU
52
54
  return errors.New("MFA enrollment confirmation is incomplete")
53
55
  }
54
56
  return tx.From(ctx, s.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
55
- var enrollment model.MFAEnrollment
57
+ var enrollment MFAEnrollment
56
58
  if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).First(&enrollment).Error; err != nil {
57
59
  return err
58
60
  }
@@ -66,12 +68,12 @@ func (s *PostgresMFAStore) ConfirmEnrollment(ctx context.Context, userID uuid.UU
66
68
  }).Error; err != nil {
67
69
  return err
68
70
  }
69
- if err := db.Where("user_id = ?", userID).Delete(&model.MFARecoveryCode{}).Error; err != nil {
71
+ if err := db.Where("user_id = ?", userID).Delete(&MFARecoveryCode{}).Error; err != nil {
70
72
  return err
71
73
  }
72
- rows := make([]model.MFARecoveryCode, len(recoveryCodeHashes))
74
+ rows := make([]MFARecoveryCode, len(recoveryCodeHashes))
73
75
  for i, hash := range recoveryCodeHashes {
74
- rows[i] = model.MFARecoveryCode{UserID: userID, CodeHash: hash}
76
+ rows[i] = MFARecoveryCode{UserID: userID, CodeHash: hash}
75
77
  }
76
78
  return db.Create(&rows).Error
77
79
  })
@@ -79,33 +81,33 @@ func (s *PostgresMFAStore) ConfirmEnrollment(ctx context.Context, userID uuid.UU
79
81
 
80
82
  func (s *PostgresMFAStore) Disable(ctx context.Context, userID uuid.UUID) error {
81
83
  return tx.From(ctx, s.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
82
- if err := db.Where("user_id = ?", userID).Delete(&model.MFARecoveryCode{}).Error; err != nil {
84
+ if err := db.Where("user_id = ?", userID).Delete(&MFARecoveryCode{}).Error; err != nil {
83
85
  return err
84
86
  }
85
- return db.Where("user_id = ?", userID).Delete(&model.MFAEnrollment{}).Error
87
+ return db.Where("user_id = ?", userID).Delete(&MFAEnrollment{}).Error
86
88
  })
87
89
  }
88
90
 
89
- func (s *PostgresMFAStore) CreateChallenge(ctx context.Context, hash string, challenge MFAChallenge) error {
91
+ func (s *PostgresMFAStore) CreateChallenge(ctx context.Context, hash string, challenge ports.MFAChallenge) error {
90
92
  if hash == "" || challenge.UserID == uuid.Nil || !challenge.ExpiresAt.After(time.Now()) {
91
93
  return fmt.Errorf("MFA challenge is invalid")
92
94
  }
93
- row := model.MFAChallenge{ChallengeHash: hash, UserID: challenge.UserID, ExpiresAt: challenge.ExpiresAt}
95
+ row := MFAChallenge{ChallengeHash: hash, UserID: challenge.UserID, ExpiresAt: challenge.ExpiresAt}
94
96
  return tx.From(ctx, s.db).WithContext(ctx).Create(&row).Error
95
97
  }
96
98
 
97
- func (s *PostgresMFAStore) ConsumeChallenge(ctx context.Context, hash string) (MFAChallenge, bool, error) {
98
- var challenge MFAChallenge
99
+ func (s *PostgresMFAStore) ConsumeChallenge(ctx context.Context, hash string) (ports.MFAChallenge, bool, error) {
100
+ var challenge ports.MFAChallenge
99
101
  err := tx.From(ctx, s.db).WithContext(ctx).Raw(
100
102
  `DELETE FROM user_svc.mfa_challenges
101
103
  WHERE challenge_hash = ? AND expires_at > now()
102
104
  RETURNING user_id, expires_at`, hash,
103
105
  ).Row().Scan(&challenge.UserID, &challenge.ExpiresAt)
104
106
  if errors.Is(err, sql.ErrNoRows) {
105
- return MFAChallenge{}, false, nil
107
+ return ports.MFAChallenge{}, false, nil
106
108
  }
107
109
  if err != nil {
108
- return MFAChallenge{}, false, err
110
+ return ports.MFAChallenge{}, false, err
109
111
  }
110
112
  return challenge, true, nil
111
113
  }
@@ -1,4 +1,4 @@
1
- package user
1
+ package postgres
2
2
 
3
3
  import (
4
4
  "context"
@@ -7,7 +7,7 @@ import (
7
7
  "testing"
8
8
  "time"
9
9
 
10
- "{{goModule}}/internal/app/user/model"
10
+ "{{goModule}}/internal/app/user/ports"
11
11
 
12
12
  "github.com/google/uuid"
13
13
  "gorm.io/driver/postgres"
@@ -53,10 +53,10 @@ func TestPostgresMFAStore_ConsumesChallengesAndRecoveryCodesOnce(t *testing.T) {
53
53
  store := NewPostgresMFAStore(db)
54
54
  ctx := context.Background()
55
55
  userID := uuid.New()
56
- if err := db.Create(&model.User{ID: userID, Email: "mfa-store-" + uuid.NewString() + "@example.com"}).Error; err != nil {
56
+ if err := db.Create(&User{ID: userID, Email: "mfa-store-" + uuid.NewString() + "@example.com"}).Error; err != nil {
57
57
  t.Fatalf("create test user: %v", err)
58
58
  }
59
- t.Cleanup(func() { _ = db.Delete(&model.User{}, "id = ?", userID).Error })
59
+ t.Cleanup(func() { _ = db.Delete(&User{}, "id = ?", userID).Error })
60
60
 
61
61
  if err := store.PutPendingEnrollment(ctx, userID, "encrypted-secret"); err != nil {
62
62
  t.Fatalf("put pending enrollment: %v", err)
@@ -76,7 +76,7 @@ func TestPostgresMFAStore_ConsumesChallengesAndRecoveryCodesOnce(t *testing.T) {
76
76
  }
77
77
 
78
78
  challengeHash := "challenge-hash-real-pg-" + uuid.NewString()
79
- if err := store.CreateChallenge(ctx, challengeHash, MFAChallenge{UserID: userID, ExpiresAt: time.Now().Add(time.Minute)}); err != nil {
79
+ if err := store.CreateChallenge(ctx, challengeHash, ports.MFAChallenge{UserID: userID, ExpiresAt: time.Now().Add(time.Minute)}); err != nil {
80
80
  t.Fatalf("create challenge: %v", err)
81
81
  }
82
82
  consumeChallengeConcurrently(t, store, challengeHash, userID)
@@ -95,7 +95,7 @@ func consumeChallengeConcurrently(t *testing.T, store *PostgresMFAStore, hash st
95
95
  const callers = 16
96
96
  start := make(chan struct{})
97
97
  results := make(chan struct {
98
- challenge MFAChallenge
98
+ challenge ports.MFAChallenge
99
99
  found bool
100
100
  err error
101
101
  }, callers)
@@ -107,7 +107,7 @@ func consumeChallengeConcurrently(t *testing.T, store *PostgresMFAStore, hash st
107
107
  <-start
108
108
  challenge, found, err := store.ConsumeChallenge(context.Background(), hash)
109
109
  results <- struct {
110
- challenge MFAChallenge
110
+ challenge ports.MFAChallenge
111
111
  found bool
112
112
  err error
113
113
  }{challenge: challenge, found: found, err: err}
@@ -0,0 +1,84 @@
1
+ // Package postgres contains the user module's PostgreSQL adapters and their
2
+ // persistence-only records. These types never cross into application code.
3
+ package postgres
4
+
5
+ import (
6
+ "time"
7
+
8
+ "github.com/google/uuid"
9
+ )
10
+
11
+ type User struct {
12
+ ID uuid.UUID `gorm:"type:uuid;primaryKey"`
13
+ Email string `gorm:"uniqueIndex:idx_users_email;not null"`
14
+ Name string
15
+ AvatarURL string
16
+ EmailVerified bool
17
+ Role string `gorm:"type:varchar(20);not null;default:'staff'"`
18
+ CreatedAt time.Time
19
+ UpdatedAt time.Time
20
+ }
21
+
22
+ func (User) TableName() string { return "user_svc.users" }
23
+
24
+ type Identity struct {
25
+ ID uuid.UUID `gorm:"type:uuid;primaryKey"`
26
+ UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_identities_user_provider,priority:1"`
27
+ Provider string `gorm:"type:varchar(20);not null;uniqueIndex:idx_identities_user_provider,priority:2;uniqueIndex:idx_identities_provider_uid,priority:1"`
28
+ PasswordHash *string
29
+ ProviderUID *string `gorm:"column:provider_uid;uniqueIndex:idx_identities_provider_uid,priority:2"`
30
+ CreatedAt time.Time
31
+ UpdatedAt time.Time
32
+ }
33
+
34
+ func (Identity) TableName() string { return "user_svc.identities" }
35
+
36
+ type AuthToken struct {
37
+ TokenHash string `gorm:"primaryKey;type:text"`
38
+ UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_auth_tokens_user_kind,priority:1"`
39
+ Kind string `gorm:"type:varchar(20);not null;index:idx_auth_tokens_user_kind,priority:2"`
40
+ ExpiresAt time.Time `gorm:"not null;index:idx_auth_tokens_expires_at"`
41
+ AbsoluteExpiresAt *time.Time `gorm:"index:idx_auth_tokens_absolute_expires_at"`
42
+ Provider string `gorm:"type:varchar(20);not null;default:''"`
43
+ CodeChallenge string `gorm:"type:text;not null;default:''"`
44
+ Nonce string `gorm:"type:text;not null;default:''"`
45
+ CreatedAt time.Time
46
+ }
47
+
48
+ func (AuthToken) TableName() string { return "user_svc.auth_tokens" }
49
+
50
+ type LoginThrottle struct {
51
+ EmailHash string `gorm:"primaryKey;type:text"`
52
+ Failures int `gorm:"not null;default:0"`
53
+ LockedUntil *time.Time
54
+ UpdatedAt time.Time
55
+ }
56
+
57
+ func (LoginThrottle) TableName() string { return "user_svc.login_throttle" }
58
+
59
+ type MFAEnrollment struct {
60
+ UserID uuid.UUID `gorm:"type:uuid;primaryKey"`
61
+ EncryptedSecret string `gorm:"type:text;not null"`
62
+ Enabled bool `gorm:"not null;default:false"`
63
+ CreatedAt time.Time
64
+ UpdatedAt time.Time
65
+ }
66
+
67
+ func (MFAEnrollment) TableName() string { return "user_svc.mfa_enrollments" }
68
+
69
+ type MFAChallenge struct {
70
+ ChallengeHash string `gorm:"type:text;primaryKey"`
71
+ UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_mfa_challenges_user"`
72
+ ExpiresAt time.Time `gorm:"not null;index:idx_mfa_challenges_expires_at"`
73
+ CreatedAt time.Time
74
+ }
75
+
76
+ func (MFAChallenge) TableName() string { return "user_svc.mfa_challenges" }
77
+
78
+ type MFARecoveryCode struct {
79
+ UserID uuid.UUID `gorm:"type:uuid;primaryKey;index:idx_mfa_recovery_codes_user"`
80
+ CodeHash string `gorm:"type:text;primaryKey"`
81
+ CreatedAt time.Time
82
+ }
83
+
84
+ func (MFARecoveryCode) TableName() string { return "user_svc.mfa_recovery_codes" }
@@ -0,0 +1,211 @@
1
+ package postgres
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "time"
7
+
8
+ "{{goModule}}/internal/app/user/domain"
9
+ "{{goModule}}/internal/app/user/ports"
10
+ "{{goModule}}/internal/shared/dberr"
11
+ "{{goModule}}/internal/shared/tx"
12
+
13
+ "github.com/google/uuid"
14
+ "gorm.io/gorm"
15
+ )
16
+
17
+ type Repository struct {
18
+ db *gorm.DB
19
+ }
20
+
21
+ var _ ports.UserRepository = (*Repository)(nil)
22
+
23
+ func NewRepository(db *gorm.DB) *Repository {
24
+ return &Repository{db: db}
25
+ }
26
+
27
+ func (r *Repository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
28
+ var row User
29
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&row, "email = ?", email).Error; err != nil {
30
+ return nil, persistenceError(err)
31
+ }
32
+ return toDomainUser(&row), nil
33
+ }
34
+
35
+ func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
36
+ var row User
37
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&row, "id = ?", id).Error; err != nil {
38
+ return nil, persistenceError(err)
39
+ }
40
+ return toDomainUser(&row), nil
41
+ }
42
+
43
+ func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error {
44
+ row := fromDomainUser(user)
45
+ return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Save(&row).Error)
46
+ }
47
+
48
+ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.User, error) {
49
+ var rows []User
50
+ err := tx.From(ctx, r.db).WithContext(ctx).
51
+ Order("created_at desc").Limit(limit).Offset(offset).Find(&rows).Error
52
+ if err != nil {
53
+ return nil, persistenceError(err)
54
+ }
55
+ items := make([]domain.User, len(rows))
56
+ for i := range rows {
57
+ items[i] = *toDomainUser(&rows[i])
58
+ }
59
+ return items, nil
60
+ }
61
+
62
+ func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider domain.Provider) (*domain.Identity, error) {
63
+ var row Identity
64
+ if err := tx.From(ctx, r.db).WithContext(ctx).
65
+ First(&row, "user_id = ? AND provider = ?", userID, string(provider)).Error; err != nil {
66
+ return nil, persistenceError(err)
67
+ }
68
+ return toDomainIdentity(&row), nil
69
+ }
70
+
71
+ func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider domain.Provider, providerUID string) (*domain.Identity, error) {
72
+ var row Identity
73
+ if err := tx.From(ctx, r.db).WithContext(ctx).
74
+ First(&row, "provider = ? AND provider_uid = ?", string(provider), providerUID).Error; err != nil {
75
+ return nil, persistenceError(err)
76
+ }
77
+ return toDomainIdentity(&row), nil
78
+ }
79
+
80
+ // CreateIdentity links a new login method onto an existing user.
81
+ func (r *Repository) CreateIdentity(ctx context.Context, identity *domain.Identity) error {
82
+ row := fromDomainIdentity(identity)
83
+ return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Create(&row).Error)
84
+ }
85
+
86
+ func (r *Repository) UpdateIdentity(ctx context.Context, identity *domain.Identity) error {
87
+ row := fromDomainIdentity(identity)
88
+ return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Save(&row).Error)
89
+ }
90
+
91
+ // CreateUserWithIdentity inserts the profile and its first login method in
92
+ // one transaction. A user without an identity cannot authenticate.
93
+ func (r *Repository) CreateUserWithIdentity(ctx context.Context, user *domain.User, identity *domain.Identity) error {
94
+ userRow := fromDomainUser(user)
95
+ identityRow := fromDomainIdentity(identity)
96
+ return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
97
+ if err := db.Create(&userRow).Error; err != nil {
98
+ return persistenceError(err)
99
+ }
100
+ identityRow.UserID = userRow.ID
101
+ return persistenceError(db.Create(&identityRow).Error)
102
+ }))
103
+ }
104
+
105
+ func (r *Repository) LoginLockedUntil(ctx context.Context, key string) (time.Time, error) {
106
+ var row LoginThrottle
107
+ err := tx.From(ctx, r.db).WithContext(ctx).Where("email_hash = ?", key).Take(&row).Error
108
+ if errors.Is(err, gorm.ErrRecordNotFound) {
109
+ return time.Time{}, nil
110
+ }
111
+ if err != nil || row.LockedUntil == nil {
112
+ return time.Time{}, persistenceError(err)
113
+ }
114
+ return *row.LockedUntil, nil
115
+ }
116
+
117
+ // RecordLoginFailure uses one SQL statement so concurrent attempts cannot
118
+ // both read and write the same backoff value.
119
+ func (r *Repository) RecordLoginFailure(ctx context.Context, key string, freeAttempts int, maxLock time.Duration) error {
120
+ err := tx.From(ctx, r.db).WithContext(ctx).Exec(`
121
+ INSERT INTO user_svc.login_throttle AS t (email_hash, failures, locked_until, updated_at)
122
+ VALUES (?, 1, NULL, now())
123
+ ON CONFLICT (email_hash) DO UPDATE SET
124
+ failures = t.failures + 1,
125
+ locked_until = CASE
126
+ WHEN t.failures + 1 <= ? THEN NULL
127
+ ELSE now() + make_interval(secs => least(power(2, t.failures + 1 - ?), ?))
128
+ END,
129
+ updated_at = now()`,
130
+ key, freeAttempts, freeAttempts, maxLock.Seconds()).Error
131
+ return persistenceError(err)
132
+ }
133
+
134
+ func (r *Repository) ClearLoginFailures(ctx context.Context, key string) error {
135
+ err := tx.From(ctx, r.db).WithContext(ctx).
136
+ Where("email_hash = ?", key).
137
+ Delete(&LoginThrottle{}).Error
138
+ return persistenceError(err)
139
+ }
140
+
141
+ func persistenceError(err error) error {
142
+ if err == nil {
143
+ return nil
144
+ }
145
+ if errors.Is(err, gorm.ErrRecordNotFound) {
146
+ return domain.ErrNotFound
147
+ }
148
+ if dberr.IsDuplicate(err) {
149
+ return domain.ErrConflict
150
+ }
151
+ return err
152
+ }
153
+
154
+ func toDomainUser(row *User) *domain.User {
155
+ if row == nil {
156
+ return nil
157
+ }
158
+ return &domain.User{
159
+ ID: row.ID,
160
+ Email: row.Email,
161
+ Name: row.Name,
162
+ AvatarURL: row.AvatarURL,
163
+ EmailVerified: row.EmailVerified,
164
+ Role: row.Role,
165
+ CreatedAt: row.CreatedAt,
166
+ UpdatedAt: row.UpdatedAt,
167
+ }
168
+ }
169
+
170
+ func fromDomainUser(user *domain.User) User {
171
+ return User{
172
+ ID: user.ID,
173
+ Email: user.Email,
174
+ Name: user.Name,
175
+ AvatarURL: user.AvatarURL,
176
+ EmailVerified: user.EmailVerified,
177
+ Role: user.Role,
178
+ CreatedAt: user.CreatedAt,
179
+ UpdatedAt: user.UpdatedAt,
180
+ }
181
+ }
182
+
183
+ func toDomainIdentity(row *Identity) *domain.Identity {
184
+ if row == nil {
185
+ return nil
186
+ }
187
+ return &domain.Identity{
188
+ ID: row.ID,
189
+ UserID: row.UserID,
190
+ Provider: domain.Provider(row.Provider),
191
+ PasswordHash: row.PasswordHash,
192
+ ProviderUID: row.ProviderUID,
193
+ CreatedAt: row.CreatedAt,
194
+ UpdatedAt: row.UpdatedAt,
195
+ }
196
+ }
197
+
198
+ func fromDomainIdentity(identity *domain.Identity) Identity {
199
+ return Identity{
200
+ ID: identity.ID,
201
+ UserID: identity.UserID,
202
+ Provider: string(identity.Provider),
203
+ PasswordHash: identity.PasswordHash,
204
+ ProviderUID: identity.ProviderUID,
205
+ CreatedAt: identity.CreatedAt,
206
+ UpdatedAt: identity.UpdatedAt,
207
+ }
208
+ }
209
+
210
+ // go-scaffold:repository-methods
211
+ // go-scaffold:user-repository-methods
@@ -1,14 +1,13 @@
1
- package user
1
+ package postgres
2
2
 
3
3
  import (
4
- "time"
5
4
  "context"
6
5
  "errors"
7
6
  "os"
8
7
  "testing"
8
+ "time"
9
9
 
10
- "{{goModule}}/internal/app/user/model"
11
- "{{goModule}}/internal/shared/dberr"
10
+ "{{goModule}}/internal/app/user/domain"
12
11
 
13
12
  "github.com/google/uuid"
14
13
  "gorm.io/driver/postgres"
@@ -73,22 +72,22 @@ func TestRepository_CreateUserWithIdentity_RollsBackBothOnIdentityConflict(t *te
73
72
  ctx := context.Background()
74
73
 
75
74
  providerUID := "conflicting-provider-uid"
76
- existing := &model.User{ID: uuid.New(), Email: "first@example.com"}
77
- if err := repo.CreateUserWithIdentity(ctx, existing, &model.Identity{
78
- ID: uuid.New(), Provider: model.ProviderGoogle, ProviderUID: &providerUID,
75
+ existing := &domain.User{ID: uuid.New(), Email: "first@example.com"}
76
+ if err := repo.CreateUserWithIdentity(ctx, existing, &domain.Identity{
77
+ ID: uuid.New(), Provider: domain.ProviderGoogle, ProviderUID: &providerUID,
79
78
  }); err != nil {
80
79
  t.Fatalf("seed existing user+identity: %v", err)
81
80
  }
82
81
 
83
- blocked := &model.User{ID: uuid.New(), Email: "second@example.com"}
84
- err := repo.CreateUserWithIdentity(ctx, blocked, &model.Identity{
85
- ID: uuid.New(), Provider: model.ProviderGoogle, ProviderUID: &providerUID,
82
+ blocked := &domain.User{ID: uuid.New(), Email: "second@example.com"}
83
+ err := repo.CreateUserWithIdentity(ctx, blocked, &domain.Identity{
84
+ ID: uuid.New(), Provider: domain.ProviderGoogle, ProviderUID: &providerUID,
86
85
  })
87
- if !dberr.IsDuplicate(err) {
88
- t.Fatalf("want a duplicate-key error from the conflicting provider_uid, got %v", err)
86
+ if !errors.Is(err, domain.ErrConflict) {
87
+ t.Fatalf("want a mapped user conflict from the conflicting provider_uid, got %v", err)
89
88
  }
90
89
 
91
- if _, findErr := repo.FindByID(ctx, blocked.ID); !errors.Is(findErr, gorm.ErrRecordNotFound) {
90
+ if _, findErr := repo.FindByID(ctx, blocked.ID); !errors.Is(findErr, domain.ErrNotFound) {
92
91
  t.Fatalf("the new user must not survive a transaction whose identity insert failed, got %v", findErr)
93
92
  }
94
93
  }
@@ -103,17 +102,17 @@ func TestRepository_CreateUserWithIdentity_DuplicateEmailIsDetectable(t *testing
103
102
  ctx := context.Background()
104
103
 
105
104
  email := "dup@example.com"
106
- if err := repo.CreateUserWithIdentity(ctx, &model.User{ID: uuid.New(), Email: email}, &model.Identity{
107
- ID: uuid.New(), Provider: model.ProviderLocal,
105
+ if err := repo.CreateUserWithIdentity(ctx, &domain.User{ID: uuid.New(), Email: email}, &domain.Identity{
106
+ ID: uuid.New(), Provider: domain.ProviderLocal,
108
107
  }); err != nil {
109
108
  t.Fatalf("seed first user: %v", err)
110
109
  }
111
110
 
112
- err := repo.CreateUserWithIdentity(ctx, &model.User{ID: uuid.New(), Email: email}, &model.Identity{
113
- ID: uuid.New(), Provider: model.ProviderLocal,
111
+ err := repo.CreateUserWithIdentity(ctx, &domain.User{ID: uuid.New(), Email: email}, &domain.Identity{
112
+ ID: uuid.New(), Provider: domain.ProviderLocal,
114
113
  })
115
- if !dberr.IsDuplicate(err) {
116
- t.Fatalf("want a duplicate-key error for the reused email, got %v", err)
114
+ if !errors.Is(err, domain.ErrConflict) {
115
+ t.Fatalf("want a mapped user conflict for the reused email, got %v", err)
117
116
  }
118
117
  }
119
118