@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 application
2
2
 
3
3
  import (
4
4
  "context"
@@ -9,12 +9,9 @@ import (
9
9
  "testing"
10
10
  "time"
11
11
 
12
- "{{goModule}}/internal/app/user/model"
13
- "{{goModule}}/internal/app/user/application"
14
- "{{goModule}}/internal/shared/apperror"
12
+ "{{goModule}}/internal/app/user/domain"
15
13
 
16
14
  "github.com/google/uuid"
17
- "gorm.io/gorm"
18
15
  )
19
16
 
20
17
  // testTokenStore is the deliberately broad test-only composition used to
@@ -29,29 +26,29 @@ type testTokenStore interface {
29
26
  // three-map shape (active / used-tombstone / per-user session set) closely
30
27
  // enough to exercise rotation + reuse-detection without a real Redis.
31
28
  type fakeTokenStore struct {
32
- mu sync.Mutex
33
- txMu sync.Mutex
34
- active map[string]refreshTokenRecord
35
- used map[string]uuid.UUID
36
- sessions map[uuid.UUID]map[string]struct{}
37
- transactions map[string]loginTransaction
38
- reset map[string]uuid.UUID
39
- verify map[string]uuid.UUID
29
+ mu sync.Mutex
30
+ txMu sync.Mutex
31
+ active map[string]RefreshTokenRecord
32
+ used map[string]uuid.UUID
33
+ sessions map[uuid.UUID]map[string]struct{}
34
+ transactions map[string]LoginTransaction
35
+ reset map[string]uuid.UUID
36
+ verify map[string]uuid.UUID
40
37
  revokeAllErr error
41
38
  }
42
39
 
43
40
  func newFakeTokenStore() *fakeTokenStore {
44
41
  return &fakeTokenStore{
45
- active: map[string]refreshTokenRecord{},
42
+ active: map[string]RefreshTokenRecord{},
46
43
  used: map[string]uuid.UUID{},
47
44
  sessions: map[uuid.UUID]map[string]struct{}{},
48
- transactions: map[string]loginTransaction{},
45
+ transactions: map[string]LoginTransaction{},
49
46
  reset: map[string]uuid.UUID{},
50
47
  verify: map[string]uuid.UUID{},
51
48
  }
52
49
  }
53
50
 
54
- func (f *fakeTokenStore) SetRefreshToken(_ context.Context, hash string, token refreshTokenRecord) error {
51
+ func (f *fakeTokenStore) SetRefreshToken(_ context.Context, hash string, token RefreshTokenRecord) error {
55
52
  f.mu.Lock()
56
53
  defer f.mu.Unlock()
57
54
  f.active[hash] = token
@@ -71,12 +68,12 @@ func (f *fakeTokenStore) GetRefreshToken(_ context.Context, hash string) (uuid.U
71
68
  return token.UserID, true, nil
72
69
  }
73
70
 
74
- func (f *fakeTokenStore) ConsumeRefreshToken(_ context.Context, hash string) (refreshTokenRecord, bool, error) {
71
+ func (f *fakeTokenStore) ConsumeRefreshToken(_ context.Context, hash string) (RefreshTokenRecord, bool, error) {
75
72
  f.mu.Lock()
76
73
  defer f.mu.Unlock()
77
74
  token, ok := f.active[hash]
78
75
  if !ok || !token.ExpiresAt.After(time.Now()) {
79
- return refreshTokenRecord{}, false, nil
76
+ return RefreshTokenRecord{}, false, nil
80
77
  }
81
78
  delete(f.active, hash)
82
79
  delete(f.sessions[token.UserID], hash)
@@ -138,8 +135,8 @@ func (f *fakeTokenStore) WithTransaction(ctx context.Context, fn func(context.Co
138
135
  return err
139
136
  }
140
137
 
141
- func cloneRefreshTokenMap(in map[string]refreshTokenRecord) map[string]refreshTokenRecord {
142
- out := make(map[string]refreshTokenRecord, len(in))
138
+ func cloneRefreshTokenMap(in map[string]RefreshTokenRecord) map[string]RefreshTokenRecord {
139
+ out := make(map[string]RefreshTokenRecord, len(in))
143
140
  for key, value := range in {
144
141
  out[key] = value
145
142
  }
@@ -154,27 +151,27 @@ func cloneTokenMap(in map[string]uuid.UUID) map[string]uuid.UUID {
154
151
  return out
155
152
  }
156
153
 
157
- func cloneLoginTransactions(in map[string]loginTransaction) map[string]loginTransaction {
158
- out := make(map[string]loginTransaction, len(in))
154
+ func cloneLoginTransactions(in map[string]LoginTransaction) map[string]LoginTransaction {
155
+ out := make(map[string]LoginTransaction, len(in))
159
156
  for key, value := range in {
160
157
  out[key] = value
161
158
  }
162
159
  return out
163
160
  }
164
161
 
165
- func (f *fakeTokenStore) SetLoginTransaction(_ context.Context, hash string, transaction loginTransaction) error {
162
+ func (f *fakeTokenStore) SetLoginTransaction(_ context.Context, hash string, transaction LoginTransaction) error {
166
163
  f.mu.Lock()
167
164
  defer f.mu.Unlock()
168
165
  f.transactions[hash] = transaction
169
166
  return nil
170
167
  }
171
168
 
172
- func (f *fakeTokenStore) ConsumeLoginTransaction(_ context.Context, hash string) (loginTransaction, bool, error) {
169
+ func (f *fakeTokenStore) ConsumeLoginTransaction(_ context.Context, hash string) (LoginTransaction, bool, error) {
173
170
  f.mu.Lock()
174
171
  defer f.mu.Unlock()
175
172
  transaction, ok := f.transactions[hash]
176
173
  if !ok || !transaction.ExpiresAt.After(time.Now()) {
177
- return loginTransaction{}, false, nil
174
+ return LoginTransaction{}, false, nil
178
175
  }
179
176
  delete(f.transactions, hash)
180
177
  return transaction, true, nil
@@ -309,12 +306,13 @@ func (f *fakeMFAStore) ConsumeRecoveryCode(_ context.Context, userID uuid.UUID,
309
306
  // by the tests below (Refresh looks the user up after validating the
310
307
  // token), the rest just satisfy the interface.
311
308
  type fakeRepo struct {
312
- user *model.User
313
- identity *model.Identity
309
+ user *domain.User
310
+ identity *domain.Identity
314
311
  updateIdentityErr error
315
312
  updateUserErr error
316
313
  failures map[string]int
317
314
  lockedUntil map[string]time.Time
315
+ // go-scaffold:repository-stub-fields
318
316
  }
319
317
 
320
318
  // throttle: the fake keeps the counter in memory so the lockout path can be
@@ -346,36 +344,38 @@ func (f *fakeRepo) ClearLoginFailures(_ context.Context, key string) error {
346
344
  return nil
347
345
  }
348
346
 
349
- func (f *fakeRepo) FindByEmail(context.Context, string) (*model.User, error) {
350
- return nil, gorm.ErrRecordNotFound
347
+ func (f *fakeRepo) FindByEmail(context.Context, string) (*domain.User, error) {
348
+ return nil, domain.ErrNotFound
351
349
  }
352
- func (f *fakeRepo) FindByID(_ context.Context, id uuid.UUID) (*model.User, error) {
350
+ func (f *fakeRepo) FindByID(_ context.Context, id uuid.UUID) (*domain.User, error) {
353
351
  if f.user != nil && f.user.ID == id {
354
352
  return f.user, nil
355
353
  }
356
- return nil, gorm.ErrRecordNotFound
354
+ return nil, domain.ErrNotFound
357
355
  }
358
- func (f *fakeRepo) UpdateUser(_ context.Context, u *model.User) error {
356
+ func (f *fakeRepo) UpdateUser(_ context.Context, u *domain.User) error {
359
357
  if f.updateUserErr != nil {
360
358
  return f.updateUserErr
361
359
  }
362
360
  f.user = u
363
361
  return nil
364
362
  }
365
- func (f *fakeRepo) FindAll(context.Context, int, int) ([]model.User, error) { return nil, nil }
366
- func (f *fakeRepo) FindIdentity(_ context.Context, userID uuid.UUID, _ model.Provider) (*model.Identity, error) {
363
+ func (f *fakeRepo) FindAll(context.Context, int, int) ([]domain.User, error) { return nil, nil }
364
+ func (f *fakeRepo) FindIdentity(_ context.Context, userID uuid.UUID, _ domain.Provider) (*domain.Identity, error) {
367
365
  if f.identity != nil && f.identity.UserID == userID {
368
366
  copy := *f.identity
369
367
  return &copy, nil
370
368
  }
371
- return nil, gorm.ErrRecordNotFound
369
+ return nil, domain.ErrNotFound
370
+ }
371
+ func (f *fakeRepo) FindIdentityByProviderUID(context.Context, domain.Provider, string) (*domain.Identity, error) {
372
+ return nil, domain.ErrNotFound
372
373
  }
373
- func (f *fakeRepo) FindIdentityByProviderUID(context.Context, model.Provider, string) (*model.Identity, error) {
374
- return nil, gorm.ErrRecordNotFound
374
+ func (f *fakeRepo) CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error {
375
+ return nil
375
376
  }
376
- func (f *fakeRepo) CreateUserWithIdentity(context.Context, *model.User, *model.Identity) error { return nil }
377
- func (f *fakeRepo) CreateIdentity(context.Context, *model.Identity) error { return nil }
378
- func (f *fakeRepo) UpdateIdentity(_ context.Context, i *model.Identity) error {
377
+ func (f *fakeRepo) CreateIdentity(context.Context, *domain.Identity) error { return nil }
378
+ func (f *fakeRepo) UpdateIdentity(_ context.Context, i *domain.Identity) error {
379
379
  if f.updateIdentityErr != nil {
380
380
  return f.updateIdentityErr
381
381
  }
@@ -384,11 +384,28 @@ func (f *fakeRepo) UpdateIdentity(_ context.Context, i *model.Identity) error {
384
384
  }
385
385
 
386
386
  // go-scaffold:user-fake-repo-methods
387
+ // go-scaffold:repository-stub-methods
387
388
 
388
389
  type fakeMailer struct{}
389
390
 
390
391
  func (fakeMailer) Send(context.Context, string, string, string) error { return nil }
391
392
 
393
+ type fakePasswordHasher struct{}
394
+
395
+ func (fakePasswordHasher) Hash(password string) (string, error) { return "hashed:" + password, nil }
396
+
397
+ func (fakePasswordHasher) Compare(hash, password string) error {
398
+ if hash != "hashed:"+password {
399
+ return errors.New("password does not match")
400
+ }
401
+ return nil
402
+ }
403
+
404
+ // fakeRoles satisfies role's roleChecker interface structurally.
405
+ type fakeRoles struct{}
406
+
407
+ func (fakeRoles) CodeExists(context.Context, string) (bool, error) { return true, nil }
408
+
392
409
  // go-scaffold:user-service-test-types
393
410
 
394
411
  // newTestService is the one NewService call site every test below shares —
@@ -398,12 +415,14 @@ func (fakeMailer) Send(context.Context, string, string, string) error { return n
398
415
  func newTestService(repo repository, tokens testTokenStore) *Service {
399
416
  return NewService(Dependencies{
400
417
  Repository: repo,
418
+ Passwords: fakePasswordHasher{},
401
419
  RefreshTokens: tokens,
402
420
  OAuthTransactions: tokens,
403
421
  RecoveryTokens: tokens,
404
422
  MFA: newFakeMFAStore(),
405
423
  Mailer: fakeMailer{},
406
- Providers: application.NewProviderRegistry(),
424
+ Providers: NewProviderRegistry(),
425
+ Roles: fakeRoles{},
407
426
  // go-scaffold:user-service-test-deps
408
427
  }, AuthConfig{
409
428
  JWTSecret: "test-secret",
@@ -416,25 +435,25 @@ func newTestService(repo repository, tokens testTokenStore) *Service {
416
435
 
417
436
  func seedRefreshToken(ctx context.Context, tokens *fakeTokenStore, raw string, userID uuid.UUID, ttl time.Duration) error {
418
437
  now := time.Now()
419
- return tokens.SetRefreshToken(ctx, hashToken(raw), refreshTokenRecord{
438
+ return tokens.SetRefreshToken(ctx, hashToken(raw), RefreshTokenRecord{
420
439
  UserID: userID,
421
440
  ExpiresAt: now.Add(ttl),
422
441
  AbsoluteExpiresAt: now.Add(24 * time.Hour),
423
442
  })
424
443
  }
425
444
 
426
- func testRefreshTokenRecord(userID uuid.UUID) refreshTokenRecord {
427
- now := time.Now()
428
- return refreshTokenRecord{UserID: userID, ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(24 * time.Hour)}
429
- }
430
-
431
445
  func status(t *testing.T, err error) int {
432
446
  t.Helper()
433
- var appErr *apperror.AppError
434
- if !errors.As(err, &appErr) {
435
- t.Fatalf("expected *apperror.AppError, got %T: %v", err, err)
447
+ var ruleErr *domain.RuleError
448
+ if errors.As(err, &ruleErr) {
449
+ switch ruleErr.Code {
450
+ case "AUTH_INVALID_TOKEN", "AUTH_INVALID_CREDENTIALS":
451
+ return http.StatusUnauthorized
452
+ case "AUTH_TOO_MANY_ATTEMPTS":
453
+ return http.StatusTooManyRequests
454
+ }
436
455
  }
437
- return appErr.HTTPStatus
456
+ return http.StatusInternalServerError
438
457
  }
439
458
 
440
459
  func TestService_Refresh_RotatesTheToken(t *testing.T) {
@@ -445,7 +464,7 @@ func TestService_Refresh_RotatesTheToken(t *testing.T) {
445
464
  if err := seedRefreshToken(ctx, tokens, rawOld, userID, time.Hour); err != nil {
446
465
  t.Fatalf("seed: %v", err)
447
466
  }
448
- svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
467
+ svc := newTestService(&fakeRepo{user: &domain.User{ID: userID, Email: "a@example.com"}}, tokens)
449
468
 
450
469
  auth, err := svc.Refresh(ctx, rawOld)
451
470
  if err != nil {
@@ -470,7 +489,7 @@ func TestService_Refresh_ConcurrentPresentationHasOneWinner(t *testing.T) {
470
489
  if err := seedRefreshToken(ctx, tokens, raw, userID, time.Hour); err != nil {
471
490
  t.Fatalf("seed: %v", err)
472
491
  }
473
- svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
492
+ svc := newTestService(&fakeRepo{user: &domain.User{ID: userID, Email: "a@example.com"}}, tokens)
474
493
 
475
494
  const callers = 32
476
495
  start := make(chan struct{})
@@ -509,7 +528,7 @@ func TestService_Refresh_ReplayingARotatedOutTokenRevokesEverySession(t *testing
509
528
  if err := seedRefreshToken(ctx, tokens, rawOtherSession, userID, time.Hour); err != nil {
510
529
  t.Fatalf("seed: %v", err)
511
530
  }
512
- svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
531
+ svc := newTestService(&fakeRepo{user: &domain.User{ID: userID, Email: "a@example.com"}}, tokens)
513
532
 
514
533
  // rotate rawOld once — legitimate use, consumes + tombstones it
515
534
  if _, err := svc.Refresh(ctx, rawOld); err != nil {
@@ -533,14 +552,14 @@ func TestService_Refresh_DoesNotExtendTheAbsoluteLifetime(t *testing.T) {
533
552
  raw := "bounded-refresh-token"
534
553
  issuedAt := time.Now()
535
554
  absoluteExpiry := issuedAt.Add(2 * time.Hour)
536
- if err := tokens.SetRefreshToken(ctx, hashToken(raw), refreshTokenRecord{
555
+ if err := tokens.SetRefreshToken(ctx, hashToken(raw), RefreshTokenRecord{
537
556
  UserID: userID,
538
557
  ExpiresAt: issuedAt.Add(time.Hour),
539
558
  AbsoluteExpiresAt: absoluteExpiry,
540
559
  }); err != nil {
541
560
  t.Fatalf("seed: %v", err)
542
561
  }
543
- svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
562
+ svc := newTestService(&fakeRepo{user: &domain.User{ID: userID}}, tokens)
544
563
  rotationTime := issuedAt.Add(90 * time.Minute)
545
564
  svc.now = func() time.Time { return rotationTime }
546
565
 
@@ -571,7 +590,7 @@ func TestService_Refresh_ReuseRevokeFailureFailsClosed(t *testing.T) {
571
590
  if err := seedRefreshToken(ctx, tokens, rawOther, userID, time.Hour); err != nil {
572
591
  t.Fatalf("seed other token: %v", err)
573
592
  }
574
- svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
593
+ svc := newTestService(&fakeRepo{user: &domain.User{ID: userID}}, tokens)
575
594
  if _, err := svc.Refresh(ctx, rawOld); err != nil {
576
595
  t.Fatalf("initial rotation: %v", err)
577
596
  }
@@ -592,7 +611,7 @@ func TestService_Refresh_UnknownTokenIsRejectedWithoutRevoking(t *testing.T) {
592
611
  if err := seedRefreshToken(ctx, tokens, rawLegit, userID, time.Hour); err != nil {
593
612
  t.Fatalf("seed: %v", err)
594
613
  }
595
- svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
614
+ svc := newTestService(&fakeRepo{user: &domain.User{ID: userID}}, tokens)
596
615
 
597
616
  _, err := svc.Refresh(ctx, "never-issued-token")
598
617
  if got := status(t, err); got != http.StatusUnauthorized {
@@ -616,7 +635,7 @@ func TestService_LogoutAll_RevokesEverySessionButLeavesOthersAlone(t *testing.T)
616
635
  t.Fatalf("seed: %v", err)
617
636
  }
618
637
  }
619
- svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
638
+ svc := newTestService(&fakeRepo{user: &domain.User{ID: userID}}, tokens)
620
639
 
621
640
  if err := svc.LogoutAll(ctx, userID); err != nil {
622
641
  t.Fatalf("unexpected error: %v", err)
@@ -637,8 +656,8 @@ func TestService_ResetPassword_DBFailureAllowsRetry(t *testing.T) {
637
656
  userID := uuid.New()
638
657
  passwordHash := "old-hash"
639
658
  repo := &fakeRepo{
640
- user: &model.User{ID: userID},
641
- identity: &model.Identity{ID: uuid.New(), UserID: userID, Provider: model.ProviderLocal, PasswordHash: &passwordHash},
659
+ user: &domain.User{ID: userID},
660
+ identity: &domain.Identity{ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &passwordHash},
642
661
  updateIdentityErr: errors.New("database unavailable"),
643
662
  }
644
663
  tokens := newFakeTokenStore()
@@ -662,8 +681,8 @@ func TestService_ResetPassword_SessionRevokeFailureRestoresTokenForRetry(t *test
662
681
  userID := uuid.New()
663
682
  passwordHash := "old-hash"
664
683
  repo := &fakeRepo{
665
- user: &model.User{ID: userID},
666
- identity: &model.Identity{ID: uuid.New(), UserID: userID, Provider: model.ProviderLocal, PasswordHash: &passwordHash},
684
+ user: &domain.User{ID: userID},
685
+ identity: &domain.Identity{ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &passwordHash},
667
686
  }
668
687
  tokens := newFakeTokenStore()
669
688
  raw := "reset-token-session-store-outage"
@@ -691,7 +710,7 @@ func TestService_VerifyEmail_DBFailureAllowsRetry(t *testing.T) {
691
710
  ctx := context.Background()
692
711
  userID := uuid.New()
693
712
  repo := &fakeRepo{
694
- user: &model.User{ID: userID},
713
+ user: &domain.User{ID: userID},
695
714
  updateUserErr: errors.New("database unavailable"),
696
715
  }
697
716
  tokens := newFakeTokenStore()
@@ -715,8 +734,8 @@ func TestService_ResetPassword_DuplicateTokenIsRejected(t *testing.T) {
715
734
  userID := uuid.New()
716
735
  passwordHash := "old-hash"
717
736
  repo := &fakeRepo{
718
- user: &model.User{ID: userID},
719
- identity: &model.Identity{ID: uuid.New(), UserID: userID, Provider: model.ProviderLocal, PasswordHash: &passwordHash},
737
+ user: &domain.User{ID: userID},
738
+ identity: &domain.Identity{ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &passwordHash},
720
739
  }
721
740
  tokens := newFakeTokenStore()
722
741
  raw := "one-time-reset-token"
@@ -737,8 +756,8 @@ func TestService_ResetPassword_ConcurrentPresentationHasOneWinner(t *testing.T)
737
756
  userID := uuid.New()
738
757
  passwordHash := "old-hash"
739
758
  repo := &fakeRepo{
740
- user: &model.User{ID: userID},
741
- identity: &model.Identity{ID: uuid.New(), UserID: userID, Provider: model.ProviderLocal, PasswordHash: &passwordHash},
759
+ user: &domain.User{ID: userID},
760
+ identity: &domain.Identity{ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &passwordHash},
742
761
  }
743
762
  tokens := newFakeTokenStore()
744
763
  raw := "concurrent-reset-token"
@@ -774,7 +793,7 @@ func TestService_ResetPassword_ConcurrentPresentationHasOneWinner(t *testing.T)
774
793
  func TestService_VerifyEmail_DuplicateTokenIsRejected(t *testing.T) {
775
794
  ctx := context.Background()
776
795
  userID := uuid.New()
777
- repo := &fakeRepo{user: &model.User{ID: userID}}
796
+ repo := &fakeRepo{user: &domain.User{ID: userID}}
778
797
  tokens := newFakeTokenStore()
779
798
  raw := "one-time-verification-token"
780
799
  if err := tokens.SetEmailVerifyToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
@@ -792,7 +811,7 @@ func TestService_VerifyEmail_DuplicateTokenIsRejected(t *testing.T) {
792
811
  func TestService_VerifyEmail_ConcurrentPresentationHasOneWinner(t *testing.T) {
793
812
  ctx := context.Background()
794
813
  userID := uuid.New()
795
- repo := &fakeRepo{user: &model.User{ID: userID}}
814
+ repo := &fakeRepo{user: &domain.User{ID: userID}}
796
815
  tokens := newFakeTokenStore()
797
816
  raw := "concurrent-verification-token"
798
817
  if err := tokens.SetEmailVerifyToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
@@ -828,11 +847,11 @@ func TestService_VerifyEmail_ConcurrentPresentationHasOneWinner(t *testing.T) {
828
847
  // account, so spreading attempts across a proxy pool doesn't help.
829
848
  func TestService_Login_LocksTheAccountAfterRepeatedFailures(t *testing.T) {
830
849
  repo := &fakeRepo{
831
- user: &model.User{ID: uuid.New(), Email: "a@example.com"},
850
+ user: &domain.User{ID: uuid.New(), Email: "a@example.com"},
832
851
  failures: map[string]int{},
833
852
  }
834
853
  svc := newTestService(repo, newFakeTokenStore())
835
- in := loginInput{Email: "a@example.com", Password: "wrong"}
854
+ in := LoginInput{Email: "a@example.com", Password: "wrong"}
836
855
 
837
856
  // the free attempts answer "wrong password", not "locked"
838
857
  for i := 0; i < loginFreeAttempts; i++ {
@@ -864,9 +883,9 @@ func TestService_ForgotPassword_HasItsOwnCounter(t *testing.T) {
864
883
  // code pulls the AppError code out, so a test asserting on behaviour doesn't
865
884
  // have to care how the error is wrapped.
866
885
  func code(err error) string {
867
- var appErr *apperror.AppError
868
- if errors.As(err, &appErr) {
869
- return appErr.Code
886
+ var ruleErr *domain.RuleError
887
+ if errors.As(err, &ruleErr) {
888
+ return ruleErr.Code
870
889
  }
871
890
  return fmt.Sprintf("%v", err)
872
891
  }
@@ -1,12 +1,11 @@
1
- package user
1
+ package application
2
2
 
3
3
  import (
4
4
  "context"
5
5
  "fmt"
6
6
  "time"
7
7
 
8
- "{{goModule}}/internal/app/user/model"
9
- "{{goModule}}/internal/shared/apperror"
8
+ "{{goModule}}/internal/app/user/domain"
10
9
 
11
10
  "github.com/google/uuid"
12
11
  )
@@ -14,20 +13,20 @@ import (
14
13
  // Refresh rotates a refresh token. A replayed, already-used token revokes
15
14
  // every session for that user; an intentionally logged-out token is rejected
16
15
  // without triggering reuse detection.
17
- func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*authResponse, error) {
16
+ func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*AuthResponse, error) {
18
17
  hash := hashToken(rawRefreshToken)
19
18
  token, ok, err := s.refreshTokens.ConsumeRefreshToken(ctx, hash)
20
19
  if err != nil {
21
- return nil, apperror.NewInternal(fmt.Errorf("consume refresh token: %w", err))
20
+ return nil, fmt.Errorf("consume refresh token: %w", err)
22
21
  }
23
22
  if !ok {
24
23
  reusedBy, used, reuseErr := s.refreshTokens.IsRefreshTokenUsed(ctx, hash)
25
24
  if reuseErr != nil {
26
- return nil, apperror.NewInternal(fmt.Errorf("check refresh token reuse: %w", reuseErr))
25
+ return nil, fmt.Errorf("check refresh token reuse: %w", reuseErr)
27
26
  }
28
27
  if used {
29
28
  if revokeErr := s.refreshTokens.RevokeAllRefreshTokens(ctx, reusedBy); revokeErr != nil {
30
- return nil, apperror.NewInternal(fmt.Errorf("revoke sessions after refresh token reuse: %w", revokeErr))
29
+ return nil, fmt.Errorf("revoke sessions after refresh token reuse: %w", revokeErr)
31
30
  }
32
31
  }
33
32
  return nil, errInvalidToken()
@@ -59,17 +58,18 @@ func (s *Service) LogoutAll(ctx context.Context, userID uuid.UUID) error {
59
58
  return s.refreshTokens.RevokeAllRefreshTokens(ctx, userID)
60
59
  }
61
60
 
62
- func (s *Service) issueTokens(ctx context.Context, u *model.User, absoluteExpiresAt ...time.Time) (*authResponse, error) {
61
+ func (s *Service) issueTokens(ctx context.Context, u *domain.User, absoluteExpiresAt ...time.Time) (*AuthResponse, error) {
63
62
  access, err := s.issueAccessToken(
64
63
  u.ID,
64
+ u.Role,
65
65
  // go-scaffold:issue-access-token-args
66
66
  )
67
67
  if err != nil {
68
- return nil, apperror.NewInternal(err)
68
+ return nil, fmt.Errorf("issue access token: %w", err)
69
69
  }
70
70
  refresh, err := randomToken()
71
71
  if err != nil {
72
- return nil, apperror.NewInternal(err)
72
+ return nil, fmt.Errorf("generate refresh token: %w", err)
73
73
  }
74
74
  clock := s.now
75
75
  if clock == nil {
@@ -85,12 +85,12 @@ func (s *Service) issueTokens(ctx context.Context, u *model.User, absoluteExpire
85
85
  expiresAt = abs
86
86
  }
87
87
  if !expiresAt.After(now) || !abs.After(now) {
88
- return nil, apperror.NewInternal(fmt.Errorf("refresh token lifetime is exhausted"))
88
+ return nil, fmt.Errorf("refresh token lifetime is exhausted")
89
89
  }
90
- if err := s.refreshTokens.SetRefreshToken(ctx, hashToken(refresh), refreshTokenRecord{UserID: u.ID, ExpiresAt: expiresAt, AbsoluteExpiresAt: abs}); err != nil {
91
- return nil, apperror.NewInternal(err)
90
+ if err := s.refreshTokens.SetRefreshToken(ctx, hashToken(refresh), RefreshTokenRecord{UserID: u.ID, ExpiresAt: expiresAt, AbsoluteExpiresAt: abs}); err != nil {
91
+ return nil, fmt.Errorf("store refresh token: %w", err)
92
92
  }
93
- return &authResponse{
93
+ return &AuthResponse{
94
94
  AccessToken: access,
95
95
  RefreshToken: refresh,
96
96
  TokenType: "Bearer",
@@ -0,0 +1,14 @@
1
+ package application
2
+
3
+ import "{{goModule}}/internal/app/user/ports"
4
+
5
+ // These aliases keep the use-case files readable while the actual outbound
6
+ // contracts remain owned by the user module's ports package.
7
+ type RefreshTokenStore = ports.RefreshTokenStore
8
+ type OAuthTransactionStore = ports.OAuthTransactionStore
9
+ type RecoveryTokenStore = ports.RecoveryTokenStore
10
+ type RefreshTokenRecord = ports.RefreshTokenRecord
11
+ type LoginTransaction = ports.LoginTransaction
12
+ type MFAEnrollment = ports.MFAEnrollment
13
+ type MFAChallenge = ports.MFAChallenge
14
+ type MFAStore = ports.MFAStore
@@ -1,16 +1,14 @@
1
- package user
1
+ package application
2
2
 
3
3
  import (
4
4
  "context"
5
5
  "errors"
6
+ "fmt"
6
7
 
7
- "{{goModule}}/internal/app/user/model"
8
- "{{goModule}}/internal/shared/apperror"
8
+ "{{goModule}}/internal/app/user/domain"
9
9
  "{{goModule}}/internal/shared/id"
10
10
 
11
11
  "github.com/google/uuid"
12
- "golang.org/x/crypto/bcrypt"
13
- "gorm.io/gorm"
14
12
  )
15
13
 
16
14
  // wrapFindErr is shared by methods added through `generate method`. Keeping
@@ -18,13 +16,13 @@ import (
18
16
  // does not need to import database-specific error packages just because a
19
17
  // later method was generated.
20
18
  func wrapFindErr(err error) error {
21
- if errors.Is(err, gorm.ErrRecordNotFound) {
19
+ if errors.Is(err, domain.ErrNotFound) {
22
20
  return errNotFound()
23
21
  }
24
- return apperror.NewInternal(err)
22
+ return fmt.Errorf("find user: %w", err)
25
23
  }
26
24
 
27
- func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*model.User, error) {
25
+ func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*domain.User, error) {
28
26
  u, err := s.repo.FindByID(ctx, userID)
29
27
  if err != nil {
30
28
  return nil, wrapFindErr(err)
@@ -34,32 +32,34 @@ func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*model.User, error
34
32
 
35
33
  // List is kept for the optional RBAC admin routes. It is not part of the
36
34
  // public self-service auth surface until that feature is installed.
37
- func (s *Service) List(ctx context.Context, limit, offset int) ([]model.User, error) {
35
+ func (s *Service) List(ctx context.Context, limit, offset int) ([]domain.User, error) {
38
36
  items, err := s.repo.FindAll(ctx, limit, offset)
39
37
  if err != nil {
40
- return nil, apperror.NewInternal(err)
38
+ return nil, fmt.Errorf("list users: %w", err)
41
39
  }
42
40
  return items, nil
43
41
  }
44
42
 
45
43
  // EnsureUser is idempotent and used only by cmd/seed. It never resets an
46
44
  // existing user's password as a side effect of a later deployment.
47
- func (s *Service) EnsureUser(ctx context.Context, email, password, name string) (*model.User, error) {
45
+ func (s *Service) EnsureUser(ctx context.Context, email, password, name string) (*domain.User, error) {
48
46
  email = normalizeEmail(email)
49
47
  if u, err := s.repo.FindByEmail(ctx, email); err == nil {
50
48
  return u, nil
49
+ } else if !errors.Is(err, domain.ErrNotFound) {
50
+ return nil, fmt.Errorf("find existing user: %w", err)
51
51
  }
52
52
 
53
- hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
53
+ hash, err := s.passwords.Hash(password)
54
54
  if err != nil {
55
- return nil, apperror.NewInternal(err)
55
+ return nil, fmt.Errorf("hash password: %w", err)
56
56
  }
57
57
  hashStr := string(hash)
58
58
 
59
- u := &model.User{ID: id.New(), Email: email, Name: name}
60
- i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
59
+ u := &domain.User{ID: id.New(), Email: email, Name: name, Role: domain.DefaultRole}
60
+ i := &domain.Identity{ID: id.New(), Provider: domain.ProviderLocal, PasswordHash: &hashStr}
61
61
  if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
62
- return nil, apperror.NewInternal(err)
62
+ return nil, fmt.Errorf("create user: %w", err)
63
63
  }
64
64
  return u, nil
65
65
  }