@nakedev/go-scaffold 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/README.md +598 -306
  2. package/dist/commands/auth.js +65 -23
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/config.js +50 -0
  5. package/dist/commands/create.js +33 -2
  6. package/dist/commands/generate.js +29 -3
  7. package/dist/commands/method.js +74 -63
  8. package/dist/commands/migration.js +2 -2
  9. package/dist/commands/observability.js +4 -53
  10. package/dist/commands/rbac.js +21 -10
  11. package/dist/commands/undo.js +11 -3
  12. package/dist/commands/worker.js +15 -5
  13. package/dist/index.js +198 -59
  14. package/dist/prompts/auth-wizard.js +40 -6
  15. package/dist/prompts/create-wizard.js +42 -1
  16. package/dist/prompts/generate-wizard.js +89 -9
  17. package/dist/templates/auth-manifest.js +50 -19
  18. package/dist/templates/create-manifest.js +8 -0
  19. package/dist/templates/module-manifest.js +84 -26
  20. package/dist/templates/rbac-manifest.js +16 -11
  21. package/dist/templates/worker-manifest.js +4 -1
  22. package/dist/types.js +8 -0
  23. package/dist/utils/auth-patcher.js +124 -33
  24. package/dist/utils/config.js +167 -4
  25. package/dist/utils/docs-patcher.js +68 -0
  26. package/dist/utils/hexagonal-method-patcher.js +334 -0
  27. package/dist/utils/main-patcher.js +32 -30
  28. package/dist/utils/marker-patch.js +7 -1
  29. package/dist/utils/module-location.js +17 -11
  30. package/dist/utils/module-profile.js +32 -0
  31. package/dist/utils/platform-patcher.js +56 -7
  32. package/dist/utils/rbac-patcher.js +89 -210
  33. package/package.json +7 -2
  34. package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
  35. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  36. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  38. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  39. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  40. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  41. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  42. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  43. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  44. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  45. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  46. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  47. package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
  48. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  49. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
  50. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
  51. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
  52. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
  53. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
  54. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
  55. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
  56. package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
  57. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  58. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  59. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
  60. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
  61. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  62. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  63. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  64. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
  65. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
  66. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
  67. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
  68. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
  69. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  70. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  71. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  72. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
  73. package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
  74. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
  75. package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
  76. package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
  77. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  78. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
  79. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
  80. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
  81. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  82. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
  83. package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
  84. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  85. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
  86. package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
  87. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  88. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  89. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  90. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  91. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  92. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  93. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
  94. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  95. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  96. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  97. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  98. package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
  99. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  100. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  101. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  102. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  103. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  104. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  105. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  106. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  107. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  108. package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
  109. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  110. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  111. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  112. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  113. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  114. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  115. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
  116. package/templates/create/base/.env.example.hbs +0 -1
  117. package/templates/create/base/.golangci.yml.hbs +2 -2
  118. package/templates/create/base/AGENTS.md.hbs +279 -67
  119. package/templates/create/base/Makefile.hbs +2 -1
  120. package/templates/create/base/README.md.hbs +115 -32
  121. package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
  122. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  123. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  124. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  125. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  126. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  127. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  128. package/templates/create/features/docs/architecture.md.hbs +92 -32
  129. package/templates/create/features/docs/patterns.md.hbs +137 -91
  130. package/templates/create/features/docs/techstack.md.hbs +18 -3
  131. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  132. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  133. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  134. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  135. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  136. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  137. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  138. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  139. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  140. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  141. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  142. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  143. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  144. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  145. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  146. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  147. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  148. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  149. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  150. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  151. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  152. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  153. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  154. package/templates/generate/module/migration.up.sql.hbs +1 -1
  155. package/dist/utils/method-patcher.js +0 -357
  156. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  157. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
  158. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
  159. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
  160. package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
  161. package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
  162. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
  163. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
  164. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  165. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
  166. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
  167. package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
  168. package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
  169. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
  170. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
  171. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
  172. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  173. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  174. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  175. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  176. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  177. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  178. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  179. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  180. package/templates/generate/module/dto.go.hbs +0 -36
  181. package/templates/generate/module/errors.go.hbs +0 -33
  182. package/templates/generate/module/handler.go.hbs +0 -134
  183. package/templates/generate/module/handler_test.go.hbs +0 -174
  184. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  185. package/templates/generate/module/minimal/handler.go.hbs +0 -48
  186. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  187. package/templates/generate/module/minimal/service.go.hbs +0 -45
  188. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  189. package/templates/generate/module/model/model.go.hbs +0 -36
  190. package/templates/generate/module/repository.go.hbs +0 -103
  191. package/templates/generate/module/service.go.hbs +0 -108
  192. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -0,0 +1,174 @@
1
+ package postgres
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "sync"
7
+ "testing"
8
+ "time"
9
+
10
+ "{{goModule}}/internal/app/user/ports"
11
+
12
+ "github.com/google/uuid"
13
+ "gorm.io/driver/postgres"
14
+ "gorm.io/gorm"
15
+ )
16
+
17
+ // mfaStoreDBForTest opens the same versioned-migration database used by
18
+ // production. This is intentionally separate from the unit fake: the
19
+ // one-use guarantees depend on PostgreSQL's row-level atomicity.
20
+ func mfaStoreDBForTest(t *testing.T) *gorm.DB {
21
+ t.Helper()
22
+ dsn := os.Getenv("TEST_DB_DSN")
23
+ if dsn == "" {
24
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
25
+ t.Fatal("TEST_DB_DSN is required when REQUIRE_TEST_DB=true")
26
+ }
27
+ t.Skip("MFA store integration test skipped: set TEST_DB_DSN to a migrated PostgreSQL database")
28
+ }
29
+
30
+ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
31
+ if err != nil {
32
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
33
+ t.Fatalf("open required MFA store database: %v", err)
34
+ }
35
+ t.Skipf("MFA store integration test skipped: %v", err)
36
+ }
37
+ sqlDB, err := db.DB()
38
+ if err != nil {
39
+ t.Fatalf("get MFA store SQL handle: %v", err)
40
+ }
41
+ if err := sqlDB.Ping(); err != nil {
42
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
43
+ t.Fatalf("ping required MFA store database: %v", err)
44
+ }
45
+ t.Skipf("MFA store integration test skipped: %v", err)
46
+ }
47
+ t.Cleanup(func() { _ = sqlDB.Close() })
48
+ return db
49
+ }
50
+
51
+ func TestPostgresMFAStore_ConsumesChallengesAndRecoveryCodesOnce(t *testing.T) {
52
+ db := mfaStoreDBForTest(t)
53
+ store := NewPostgresMFAStore(db)
54
+ ctx := context.Background()
55
+ userID := uuid.New()
56
+ if err := db.Create(&User{ID: userID, Email: "mfa-store-" + uuid.NewString() + "@example.com"}).Error; err != nil {
57
+ t.Fatalf("create test user: %v", err)
58
+ }
59
+ t.Cleanup(func() { _ = db.Delete(&User{}, "id = ?", userID).Error })
60
+
61
+ if err := store.PutPendingEnrollment(ctx, userID, "encrypted-secret"); err != nil {
62
+ t.Fatalf("put pending enrollment: %v", err)
63
+ }
64
+ enrollment, found, err := store.GetEnrollment(ctx, userID)
65
+ if err != nil || !found || enrollment.Enabled {
66
+ t.Fatalf("expected pending enrollment, got found=%t enrollment=%+v err=%v", found, enrollment, err)
67
+ }
68
+
69
+ const recoveryHash = "recovery-hash-real-pg"
70
+ if err := store.ConfirmEnrollment(ctx, userID, enrollment.EncryptedSecret, []string{recoveryHash}); err != nil {
71
+ t.Fatalf("confirm enrollment: %v", err)
72
+ }
73
+ enrollment, found, err = store.GetEnrollment(ctx, userID)
74
+ if err != nil || !found || !enrollment.Enabled {
75
+ t.Fatalf("expected enabled enrollment, got found=%t enrollment=%+v err=%v", found, enrollment, err)
76
+ }
77
+
78
+ challengeHash := "challenge-hash-real-pg-" + uuid.NewString()
79
+ if err := store.CreateChallenge(ctx, challengeHash, ports.MFAChallenge{UserID: userID, ExpiresAt: time.Now().Add(time.Minute)}); err != nil {
80
+ t.Fatalf("create challenge: %v", err)
81
+ }
82
+ consumeChallengeConcurrently(t, store, challengeHash, userID)
83
+
84
+ consumeRecoveryCodeConcurrently(t, store, userID, recoveryHash)
85
+ if err := store.Disable(ctx, userID); err != nil {
86
+ t.Fatalf("disable MFA: %v", err)
87
+ }
88
+ if _, found, err := store.GetEnrollment(ctx, userID); err != nil || found {
89
+ t.Fatalf("expected disable to remove enrollment, found=%t err=%v", found, err)
90
+ }
91
+ }
92
+
93
+ func consumeChallengeConcurrently(t *testing.T, store *PostgresMFAStore, hash string, wantUser uuid.UUID) {
94
+ t.Helper()
95
+ const callers = 16
96
+ start := make(chan struct{})
97
+ results := make(chan struct {
98
+ challenge ports.MFAChallenge
99
+ found bool
100
+ err error
101
+ }, callers)
102
+ var wg sync.WaitGroup
103
+ for i := 0; i < callers; i++ {
104
+ wg.Add(1)
105
+ go func() {
106
+ defer wg.Done()
107
+ <-start
108
+ challenge, found, err := store.ConsumeChallenge(context.Background(), hash)
109
+ results <- struct {
110
+ challenge ports.MFAChallenge
111
+ found bool
112
+ err error
113
+ }{challenge: challenge, found: found, err: err}
114
+ }()
115
+ }
116
+ close(start)
117
+ wg.Wait()
118
+ close(results)
119
+
120
+ winners := 0
121
+ for result := range results {
122
+ if result.err != nil {
123
+ t.Fatalf("concurrent challenge consume: %v", result.err)
124
+ }
125
+ if result.found {
126
+ winners++
127
+ if result.challenge.UserID != wantUser {
128
+ t.Fatalf("challenge winner returned user %s, want %s", result.challenge.UserID, wantUser)
129
+ }
130
+ }
131
+ }
132
+ if winners != 1 {
133
+ t.Fatalf("expected exactly one real-database challenge winner, got %d", winners)
134
+ }
135
+ }
136
+
137
+ func consumeRecoveryCodeConcurrently(t *testing.T, store *PostgresMFAStore, userID uuid.UUID, hash string) {
138
+ t.Helper()
139
+ const callers = 16
140
+ start := make(chan struct{})
141
+ results := make(chan struct {
142
+ used bool
143
+ err error
144
+ }, callers)
145
+ var wg sync.WaitGroup
146
+ for i := 0; i < callers; i++ {
147
+ wg.Add(1)
148
+ go func() {
149
+ defer wg.Done()
150
+ <-start
151
+ used, err := store.ConsumeRecoveryCode(context.Background(), userID, hash)
152
+ results <- struct {
153
+ used bool
154
+ err error
155
+ }{used: used, err: err}
156
+ }()
157
+ }
158
+ close(start)
159
+ wg.Wait()
160
+ close(results)
161
+
162
+ winners := 0
163
+ for result := range results {
164
+ if result.err != nil {
165
+ t.Fatalf("concurrent recovery-code consume: %v", result.err)
166
+ }
167
+ if result.used {
168
+ winners++
169
+ }
170
+ }
171
+ if winners != 1 {
172
+ t.Fatalf("expected exactly one real-database recovery-code winner, got %d", winners)
173
+ }
174
+ }
@@ -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