@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"
@@ -7,39 +7,38 @@ import (
7
7
  "strings"
8
8
  "testing"
9
9
  "time"
10
-
11
- "{{goModule}}/internal/app/user/application"
12
10
  )
13
11
 
14
12
  type fakeLoginProvider struct {
15
13
  name string
16
- beginFn func(application.LoginStartInput) (application.Authorization, error)
17
- completeFn func(application.LoginCompleteInput) (application.ExternalIdentity, error)
14
+ beginFn func(LoginStartInput) (Authorization, error)
15
+ completeFn func(LoginCompleteInput) (ExternalIdentity, error)
18
16
  beginCnt int
19
17
  completeCnt int
20
18
  }
21
19
 
22
20
  func (p *fakeLoginProvider) Name() string { return p.name }
23
21
 
24
- func (p *fakeLoginProvider) Begin(_ context.Context, in application.LoginStartInput) (application.Authorization, error) {
22
+ func (p *fakeLoginProvider) Begin(_ context.Context, in LoginStartInput) (Authorization, error) {
25
23
  p.beginCnt++
26
24
  return p.beginFn(in)
27
25
  }
28
26
 
29
- func (p *fakeLoginProvider) Complete(_ context.Context, in application.LoginCompleteInput) (application.ExternalIdentity, error) {
27
+ func (p *fakeLoginProvider) Complete(_ context.Context, in LoginCompleteInput) (ExternalIdentity, error) {
30
28
  p.completeCnt++
31
29
  return p.completeFn(in)
32
30
  }
33
31
 
34
- func newTestServiceWithProviders(repo repository, tokens testTokenStore, providers ...application.LoginProvider) *Service {
32
+ func newTestServiceWithProviders(repo repository, tokens testTokenStore, providers ...LoginProvider) *Service {
35
33
  return NewService(Dependencies{
36
34
  Repository: repo,
35
+ Passwords: fakePasswordHasher{},
37
36
  RefreshTokens: tokens,
38
37
  OAuthTransactions: tokens,
39
38
  RecoveryTokens: tokens,
40
39
  MFA: newFakeMFAStore(),
41
40
  Mailer: fakeMailer{},
42
- Providers: application.NewProviderRegistry(providers...),
41
+ Providers: NewProviderRegistry(providers...),
43
42
  }, AuthConfig{
44
43
  JWTSecret: "test-secret",
45
44
  JWTAccessTTL: time.Minute,
@@ -47,49 +46,49 @@ func newTestServiceWithProviders(repo repository, tokens testTokenStore, provide
47
46
  })
48
47
  }
49
48
 
50
- func oauthErrorCode(t *testing.T, err error) application.OAuthErrorCode {
49
+ func oauthErrorCode(t *testing.T, err error) OAuthErrorCode {
51
50
  t.Helper()
52
- var oauthErr *application.OAuthError
51
+ var oauthErr *OAuthError
53
52
  if !errors.As(err, &oauthErr) {
54
- t.Fatalf("expected *application.OAuthError, got %T: %v", err, err)
53
+ t.Fatalf("expected *OAuthError, got %T: %v", err, err)
55
54
  }
56
55
  return oauthErr.Code
57
56
  }
58
57
 
59
58
  const testOAuthVerifier = "client-code-verifier-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
60
59
 
61
- func validLoginStart() application.LoginStartInput {
62
- return application.LoginStartInput{
60
+ func validLoginStart() LoginStartInput {
61
+ return LoginStartInput{
63
62
  State: "client state",
64
63
  CodeChallenge: pkceChallenge(testOAuthVerifier),
65
64
  CodeChallengeMethod: "S256",
66
65
  }
67
66
  }
68
67
 
69
- func validLoginExchange() loginExchangeInput {
70
- return loginExchangeInput{Code: "code", State: "client state", CodeVerifier: testOAuthVerifier}
68
+ func validLoginExchange() LoginExchangeInput {
69
+ return LoginExchangeInput{Code: "code", State: "client state", CodeVerifier: testOAuthVerifier}
71
70
  }
72
71
 
73
72
  func validFakeProvider(t *testing.T) (*fakeLoginProvider, *Service) {
74
73
  t.Helper()
75
74
  provider := &fakeLoginProvider{name: "fake"}
76
- provider.beginFn = func(in application.LoginStartInput) (application.Authorization, error) {
75
+ provider.beginFn = func(in LoginStartInput) (Authorization, error) {
77
76
  expected := validLoginStart()
78
77
  if in.State != expected.State || in.CodeChallenge != expected.CodeChallenge || in.CodeChallengeMethod != expected.CodeChallengeMethod || strings.TrimSpace(in.Nonce) == "" {
79
78
  t.Fatalf("unexpected begin input: %+v", in)
80
79
  }
81
- return application.Authorization{URL: "https://provider.example.test/authorize?" + url.Values{
80
+ return Authorization{URL: "https://provider.example.test/authorize?" + url.Values{
82
81
  "state": {in.State},
83
82
  "code_challenge": {in.CodeChallenge},
84
83
  "code_challenge_method": {in.CodeChallengeMethod},
85
84
  "nonce": {in.Nonce},
86
85
  }.Encode()}, nil
87
86
  }
88
- provider.completeFn = func(in application.LoginCompleteInput) (application.ExternalIdentity, error) {
87
+ provider.completeFn = func(in LoginCompleteInput) (ExternalIdentity, error) {
89
88
  if in.Code != "code" || in.CodeVerifier != testOAuthVerifier || strings.TrimSpace(in.Nonce) == "" {
90
89
  t.Fatalf("unexpected complete input: %+v", in)
91
90
  }
92
- return application.ExternalIdentity{
91
+ return ExternalIdentity{
93
92
  Provider: "fake",
94
93
  Subject: "subject-1",
95
94
  Email: "user@example.com",
@@ -122,20 +121,20 @@ func TestService_BeginLoginRejectsMissingStateOrNonS256PKCE(t *testing.T) {
122
121
  provider, svc := validFakeProvider(t)
123
122
  tests := []struct {
124
123
  name string
125
- in application.LoginStartInput
124
+ in LoginStartInput
126
125
  }{
127
- {name: "missing state", in: application.LoginStartInput{CodeChallenge: "challenge", CodeChallengeMethod: "S256"}},
128
- {name: "missing challenge", in: application.LoginStartInput{State: "state", CodeChallengeMethod: "S256"}},
129
- {name: "plain challenge method", in: application.LoginStartInput{State: "state", CodeChallenge: "challenge", CodeChallengeMethod: "plain"}},
130
- {name: "short challenge", in: application.LoginStartInput{State: "state", CodeChallenge: "short", CodeChallengeMethod: "S256"}},
131
- {name: "invalid challenge character", in: application.LoginStartInput{State: "state", CodeChallenge: strings.Repeat("!", 43), CodeChallengeMethod: "S256"}},
132
- {name: "lowercase challenge method", in: application.LoginStartInput{State: "state", CodeChallenge: strings.Repeat("A", 43), CodeChallengeMethod: "s256"}},
126
+ {name: "missing state", in: LoginStartInput{CodeChallenge: "challenge", CodeChallengeMethod: "S256"}},
127
+ {name: "missing challenge", in: LoginStartInput{State: "state", CodeChallengeMethod: "S256"}},
128
+ {name: "plain challenge method", in: LoginStartInput{State: "state", CodeChallenge: "challenge", CodeChallengeMethod: "plain"}},
129
+ {name: "short challenge", in: LoginStartInput{State: "state", CodeChallenge: "short", CodeChallengeMethod: "S256"}},
130
+ {name: "invalid challenge character", in: LoginStartInput{State: "state", CodeChallenge: strings.Repeat("!", 43), CodeChallengeMethod: "S256"}},
131
+ {name: "lowercase challenge method", in: LoginStartInput{State: "state", CodeChallenge: strings.Repeat("A", 43), CodeChallengeMethod: "s256"}},
133
132
  }
134
133
  for _, tt := range tests {
135
134
  t.Run(tt.name, func(t *testing.T) {
136
135
  _, err := svc.BeginLogin(context.Background(), "fake", tt.in)
137
- if got := oauthErrorCode(t, err); got != application.OAuthStateInvalid {
138
- t.Fatalf("oauth code = %q, want %q", got, application.OAuthStateInvalid)
136
+ if got := oauthErrorCode(t, err); got != OAuthStateInvalid {
137
+ t.Fatalf("oauth code = %q, want %q", got, OAuthStateInvalid)
139
138
  }
140
139
  if provider.beginCnt != 0 {
141
140
  t.Fatal("provider must not receive an invalid state/PKCE request")
@@ -163,8 +162,8 @@ func TestService_ExchangeLoginRejectsIdentityFromAnotherProvider(t *testing.T) {
163
162
  if _, err := svc.BeginLogin(context.Background(), "fake", validLoginStart()); err != nil {
164
163
  t.Fatalf("begin login: %v", err)
165
164
  }
166
- provider.completeFn = func(application.LoginCompleteInput) (application.ExternalIdentity, error) {
167
- return application.ExternalIdentity{
165
+ provider.completeFn = func(LoginCompleteInput) (ExternalIdentity, error) {
166
+ return ExternalIdentity{
168
167
  Provider: "another-provider",
169
168
  Subject: "subject-1",
170
169
  Email: "user@example.com",
@@ -172,8 +171,8 @@ func TestService_ExchangeLoginRejectsIdentityFromAnotherProvider(t *testing.T) {
172
171
  }, nil
173
172
  }
174
173
  _, err := svc.ExchangeLogin(context.Background(), "fake", validLoginExchange())
175
- if got := oauthErrorCode(t, err); got != application.OAuthFailed {
176
- t.Fatalf("oauth code = %q, want %q", got, application.OAuthFailed)
174
+ if got := oauthErrorCode(t, err); got != OAuthFailed {
175
+ t.Fatalf("oauth code = %q, want %q", got, OAuthFailed)
177
176
  }
178
177
  }
179
178
 
@@ -181,20 +180,20 @@ func TestService_ExchangeLoginMapsProviderErrorsToControlledCodes(t *testing.T)
181
180
  tests := []struct {
182
181
  name string
183
182
  providerError error
184
- wantCode application.OAuthErrorCode
183
+ wantCode OAuthErrorCode
185
184
  }{
186
- {name: "provider exchange failure", providerError: application.NewProviderFailure(errors.New("exchange failed")), wantCode: application.OAuthFailed},
187
- {name: "provider unavailable", providerError: application.NewProviderUnavailable(errors.New("upstream timeout")), wantCode: application.OAuthProviderUnavailable},
185
+ {name: "provider exchange failure", providerError: NewProviderFailure(errors.New("exchange failed")), wantCode: OAuthFailed},
186
+ {name: "provider unavailable", providerError: NewProviderUnavailable(errors.New("upstream timeout")), wantCode: OAuthProviderUnavailable},
188
187
  }
189
188
 
190
189
  for _, tt := range tests {
191
- t.Run(tt.name, func(t *testing.T) {
190
+ t.Run(tt.name, func(t *testing.T) {
192
191
  provider, svc := validFakeProvider(t)
193
192
  if _, err := svc.BeginLogin(context.Background(), "fake", validLoginStart()); err != nil {
194
193
  t.Fatalf("begin login: %v", err)
195
194
  }
196
- provider.completeFn = func(application.LoginCompleteInput) (application.ExternalIdentity, error) {
197
- return application.ExternalIdentity{}, tt.providerError
195
+ provider.completeFn = func(LoginCompleteInput) (ExternalIdentity, error) {
196
+ return ExternalIdentity{}, tt.providerError
198
197
  }
199
198
  _, err := svc.ExchangeLogin(context.Background(), "fake", validLoginExchange())
200
199
  if got := oauthErrorCode(t, err); got != tt.wantCode {
@@ -208,12 +207,12 @@ func TestService_ExchangeLoginRejectsMissingStatePKCEAndCode(t *testing.T) {
208
207
  _, svc := validFakeProvider(t)
209
208
  tests := []struct {
210
209
  name string
211
- in loginExchangeInput
212
- want application.OAuthErrorCode
210
+ in LoginExchangeInput
211
+ want OAuthErrorCode
213
212
  }{
214
- {name: "missing state", in: loginExchangeInput{Code: "code", CodeVerifier: testOAuthVerifier}, want: application.OAuthStateInvalid},
215
- {name: "missing verifier", in: loginExchangeInput{Code: "code", State: "state"}, want: application.OAuthStateInvalid},
216
- {name: "missing code", in: loginExchangeInput{State: "state", CodeVerifier: testOAuthVerifier}, want: application.OAuthFailed},
213
+ {name: "missing state", in: LoginExchangeInput{Code: "code", CodeVerifier: testOAuthVerifier}, want: OAuthStateInvalid},
214
+ {name: "missing verifier", in: LoginExchangeInput{Code: "code", State: "state"}, want: OAuthStateInvalid},
215
+ {name: "missing code", in: LoginExchangeInput{State: "state", CodeVerifier: testOAuthVerifier}, want: OAuthFailed},
217
216
  }
218
217
  for _, tt := range tests {
219
218
  t.Run(tt.name, func(t *testing.T) {
@@ -232,10 +231,10 @@ func TestService_ExchangeLoginConsumesStateAndBindsThePKCEVerifier(t *testing.T)
232
231
  }
233
232
  wrong := validLoginExchange()
234
233
  wrong.CodeVerifier = testOAuthVerifier + "x"
235
- if _, err := svc.ExchangeLogin(context.Background(), "fake", wrong); oauthErrorCode(t, err) != application.OAuthStateInvalid {
234
+ if _, err := svc.ExchangeLogin(context.Background(), "fake", wrong); oauthErrorCode(t, err) != OAuthStateInvalid {
236
235
  t.Fatalf("wrong verifier should be rejected as state invalid: %v", err)
237
236
  }
238
- if _, err := svc.ExchangeLogin(context.Background(), "fake", validLoginExchange()); oauthErrorCode(t, err) != application.OAuthStateInvalid {
237
+ if _, err := svc.ExchangeLogin(context.Background(), "fake", validLoginExchange()); oauthErrorCode(t, err) != OAuthStateInvalid {
239
238
  t.Fatalf("a rejected exchange must consume the one-time transaction: %v", err)
240
239
  }
241
240
  if provider.completeCnt != 0 {
@@ -249,18 +248,18 @@ func TestService_UnconfiguredProviderDoesNotBuildAURL(t *testing.T) {
249
248
  if start != nil {
250
249
  t.Fatalf("unconfigured provider returned a login start: %+v", start)
251
250
  }
252
- if got := oauthErrorCode(t, err); got != application.OAuthProviderUnavailable {
253
- t.Fatalf("oauth code = %q, want %q", got, application.OAuthProviderUnavailable)
251
+ if got := oauthErrorCode(t, err); got != OAuthProviderUnavailable {
252
+ t.Fatalf("oauth code = %q, want %q", got, OAuthProviderUnavailable)
254
253
  }
255
254
  _, err = svc.ExchangeLogin(context.Background(), "google", validLoginExchange())
256
- if got := oauthErrorCode(t, err); got != application.OAuthProviderUnavailable {
257
- t.Fatalf("exchange oauth code = %q, want %q", got, application.OAuthProviderUnavailable)
255
+ if got := oauthErrorCode(t, err); got != OAuthProviderUnavailable {
256
+ t.Fatalf("exchange oauth code = %q, want %q", got, OAuthProviderUnavailable)
258
257
  }
259
258
  }
260
259
 
261
260
  func TestProviderRegistry_UsesProviderNameAsTheOnlyLookupKey(t *testing.T) {
262
261
  provider := &fakeLoginProvider{name: "fake"}
263
- registry := application.NewProviderRegistry(provider)
262
+ registry := NewProviderRegistry(provider)
264
263
  if got, ok := registry.Lookup("fake"); !ok || got != provider {
265
264
  t.Fatalf("registry lookup = (%v, %v), want fake provider", got, ok)
266
265
  }
@@ -270,17 +269,17 @@ func TestProviderRegistry_UsesProviderNameAsTheOnlyLookupKey(t *testing.T) {
270
269
  }
271
270
 
272
271
  func TestService_BeginLoginRejectsEmptyProviderAuthorizationURL(t *testing.T) {
273
- provider := &fakeLoginProvider{name: "fake", beginFn: func(application.LoginStartInput) (application.Authorization, error) {
274
- return application.Authorization{}, nil
272
+ provider := &fakeLoginProvider{name: "fake", beginFn: func(LoginStartInput) (Authorization, error) {
273
+ return Authorization{}, nil
275
274
  }}
276
275
  svc := newTestServiceWithProviders(&fakeRepo{}, newFakeTokenStore(), provider)
277
276
  _, err := svc.BeginLogin(context.Background(), "fake", validLoginStart())
278
- if got := oauthErrorCode(t, err); got != application.OAuthProviderUnavailable {
279
- t.Fatalf("oauth code = %q, want %q", got, application.OAuthProviderUnavailable)
277
+ if got := oauthErrorCode(t, err); got != OAuthProviderUnavailable {
278
+ t.Fatalf("oauth code = %q, want %q", got, OAuthProviderUnavailable)
280
279
  }
281
280
  if strings.Contains(err.Error(), "Location") {
282
281
  t.Fatalf("empty authorization must not become a redirect: %v", err)
283
282
  }
284
283
  }
285
284
 
286
- var _ application.LoginProvider = (*fakeLoginProvider)(nil)
285
+ var _ LoginProvider = (*fakeLoginProvider)(nil)
@@ -1,6 +1,3 @@
1
- // Package application contains auth use cases and ports that are independent
2
- // of Gin, GORM models, and HTTP error payloads. Adapters in the parent user
3
- // package translate those contracts at the feature boundary.
4
1
  package application
5
2
 
6
3
  import (
@@ -8,55 +5,24 @@ import (
8
5
  "errors"
9
6
  "fmt"
10
7
 
8
+ "{{goModule}}/internal/app/user/domain"
9
+ "{{goModule}}/internal/app/user/ports"
10
+
11
11
  "github.com/google/uuid"
12
12
  )
13
13
 
14
- var ErrInvalidToken = errors.New("invalid or expired one-time token")
15
-
16
- type Identity struct {
17
- ID uuid.UUID
18
- UserID uuid.UUID
19
- PasswordHash string
20
- }
21
-
22
- type User struct {
23
- ID uuid.UUID
24
- EmailVerified bool
25
- }
26
-
27
- // RecoveryRepository is an outbound port owned by the recovery use case.
28
- // Implementations map persistence rows to these plain application values.
29
- type RecoveryRepository interface {
30
- FindIdentity(context.Context, uuid.UUID) (*Identity, error)
31
- UpdateIdentity(context.Context, *Identity) error
32
- FindUser(context.Context, uuid.UUID) (*User, error)
33
- UpdateUser(context.Context, *User) error
34
- }
35
-
36
- type RecoveryTokens interface {
37
- WithTransaction(context.Context, func(context.Context) error) error
38
- ConsumePasswordResetToken(context.Context, string) (uuid.UUID, bool, error)
39
- ConsumeEmailVerifyToken(context.Context, string) (uuid.UUID, bool, error)
40
- }
41
-
42
- type PasswordHasher interface {
43
- Hash(string) (string, error)
44
- }
45
-
46
14
  type Recovery struct {
47
- repo RecoveryRepository
48
- tokens RecoveryTokens
15
+ repo ports.UserRepository
16
+ tokens ports.RecoveryTokenStore
49
17
  hasher PasswordHasher
50
18
  }
51
19
 
52
- func NewRecovery(repo RecoveryRepository, tokens RecoveryTokens, hasher PasswordHasher) *Recovery {
20
+ func NewRecovery(repo ports.UserRepository, tokens ports.RecoveryTokenStore, hasher PasswordHasher) *Recovery {
53
21
  return &Recovery{repo: repo, tokens: tokens, hasher: hasher}
54
22
  }
55
23
 
56
- // ResetPassword consumes the token and updates the identity in one unit of
57
- // work. Postgres-backed token adapters run both writes through the same DB
58
- // transaction; the contract is deliberately a port so another adapter can
59
- // provide an equivalent retry-safe boundary.
24
+ // ResetPassword consumes the token and updates the local identity in one
25
+ // unit of work. The token adapter owns the transaction boundary.
60
26
  func (r *Recovery) ResetPassword(ctx context.Context, tokenHash, newPassword string) (uuid.UUID, error) {
61
27
  hash, err := r.hasher.Hash(newPassword)
62
28
  if err != nil {
@@ -70,14 +36,17 @@ func (r *Recovery) ResetPassword(ctx context.Context, tokenHash, newPassword str
70
36
  return fmt.Errorf("consume password reset token: %w", err)
71
37
  }
72
38
  if !ok {
73
- return ErrInvalidToken
39
+ return domain.ErrInvalidToken
74
40
  }
75
41
 
76
- identity, err := r.repo.FindIdentity(txctx, id)
42
+ identity, err := r.repo.FindIdentity(txctx, id, domain.ProviderLocal)
77
43
  if err != nil {
78
44
  return fmt.Errorf("find local identity: %w", err)
79
45
  }
80
- identity.PasswordHash = hash
46
+ if identity.PasswordHash == nil {
47
+ return errors.New("local identity has no password")
48
+ }
49
+ identity.PasswordHash = &hash
81
50
  if err := r.repo.UpdateIdentity(txctx, identity); err != nil {
82
51
  return fmt.Errorf("update password: %w", err)
83
52
  }
@@ -95,10 +64,10 @@ func (r *Recovery) VerifyEmail(ctx context.Context, tokenHash string) (uuid.UUID
95
64
  return fmt.Errorf("consume email verification token: %w", err)
96
65
  }
97
66
  if !ok {
98
- return ErrInvalidToken
67
+ return domain.ErrInvalidToken
99
68
  }
100
69
 
101
- user, err := r.repo.FindUser(txctx, id)
70
+ user, err := r.repo.FindByID(txctx, id)
102
71
  if err != nil {
103
72
  return fmt.Errorf("find user for email verification: %w", err)
104
73
  }
@@ -1,4 +1,4 @@
1
- package user
1
+ package application
2
2
 
3
3
  import (
4
4
  "context"
@@ -6,9 +6,7 @@ import (
6
6
  "fmt"
7
7
  "log/slog"
8
8
 
9
- "{{goModule}}/internal/app/user/application"
10
- "{{goModule}}/internal/app/user/model"
11
- "{{goModule}}/internal/shared/apperror"
9
+ "{{goModule}}/internal/app/user/domain"
12
10
 
13
11
  "github.com/google/uuid"
14
12
  )
@@ -29,17 +27,17 @@ func (s *Service) ForgotPassword(ctx context.Context, email string) error {
29
27
  if err != nil {
30
28
  return nil
31
29
  }
32
- ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
30
+ ident, err := s.repo.FindIdentity(ctx, u.ID, domain.ProviderLocal)
33
31
  if err != nil || ident.PasswordHash == nil {
34
32
  return nil
35
33
  }
36
34
 
37
35
  raw, err := randomToken()
38
36
  if err != nil {
39
- return apperror.NewInternal(err)
37
+ return fmt.Errorf("generate password reset token: %w", err)
40
38
  }
41
39
  if err := s.recoveryTokens.SetPasswordResetToken(ctx, hashToken(raw), u.ID, s.config.PasswordResetTTL); err != nil {
42
- return apperror.NewInternal(err)
40
+ return fmt.Errorf("store password reset token: %w", err)
43
41
  }
44
42
 
45
43
  link := s.config.PasswordResetURL + "?token=" + raw
@@ -58,23 +56,23 @@ func (s *Service) ResetPassword(ctx context.Context, rawToken, newPassword strin
58
56
  tokenHash := hashToken(rawToken)
59
57
  userID, err := s.recovery.ResetPassword(ctx, tokenHash, newPassword)
60
58
  if err != nil {
61
- if errors.Is(err, application.ErrInvalidToken) {
59
+ if errors.Is(err, domain.ErrInvalidToken) {
62
60
  return errInvalidToken()
63
61
  }
64
- return apperror.NewInternal(fmt.Errorf("reset password: %w", err))
62
+ return fmt.Errorf("reset password: %w", err)
65
63
  }
66
64
  if err := s.refreshTokens.RevokeAllRefreshTokens(ctx, userID); err != nil {
67
65
  if restoreErr := s.recoveryTokens.SetPasswordResetToken(ctx, tokenHash, userID, s.config.PasswordResetTTL); restoreErr != nil {
68
- return apperror.NewInternal(fmt.Errorf("revoke sessions after password reset: %w; restore reset token for retry: %v", err, restoreErr))
66
+ return fmt.Errorf("revoke sessions after password reset: %w; restore reset token for retry: %v", err, restoreErr)
69
67
  }
70
- return apperror.NewInternal(fmt.Errorf("revoke sessions after password reset (reset token restored for retry): %w", err))
68
+ return fmt.Errorf("revoke sessions after password reset (reset token restored for retry): %w", err)
71
69
  }
72
70
  return nil
73
71
  }
74
72
 
75
73
  // sendVerificationEmail issues a one-time token and sends its link. It is
76
74
  // shared by registration and the authenticated resend flow.
77
- func (s *Service) sendVerificationEmail(ctx context.Context, u *model.User) {
75
+ func (s *Service) sendVerificationEmail(ctx context.Context, u *domain.User) {
78
76
  raw, err := randomToken()
79
77
  if err != nil {
80
78
  slog.Error("generate email verification token", "error", err)
@@ -93,7 +91,7 @@ func (s *Service) sendVerificationEmail(ctx context.Context, u *model.User) {
93
91
  func (s *Service) ResendVerificationEmail(ctx context.Context, userID uuid.UUID) error {
94
92
  u, err := s.repo.FindByID(ctx, userID)
95
93
  if err != nil {
96
- return apperror.NewInternal(err)
94
+ return wrapFindErr(err)
97
95
  }
98
96
  if u.EmailVerified {
99
97
  return errAlreadyVerified()
@@ -105,10 +103,10 @@ func (s *Service) ResendVerificationEmail(ctx context.Context, userID uuid.UUID)
105
103
  func (s *Service) VerifyEmail(ctx context.Context, rawToken string) error {
106
104
  _, err := s.recovery.VerifyEmail(ctx, hashToken(rawToken))
107
105
  if err != nil {
108
- if errors.Is(err, application.ErrInvalidToken) {
106
+ if errors.Is(err, domain.ErrInvalidToken) {
109
107
  return errInvalidToken()
110
108
  }
111
- return apperror.NewInternal(fmt.Errorf("verify email: %w", err))
109
+ return fmt.Errorf("verify email: %w", err)
112
110
  }
113
111
  return nil
114
112
  }
@@ -0,0 +1,145 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "strings"
7
+ "time"
8
+
9
+ "{{goModule}}/internal/app/user/domain"
10
+ "{{goModule}}/internal/app/user/ports"
11
+
12
+ "github.com/google/uuid"
13
+ )
14
+
15
+ // repository is a package-local name used by focused application tests and
16
+ // generated method extensions. The actual contract is owned by ports.
17
+ type repository = ports.UserRepository
18
+
19
+ // Service coordinates user authentication use cases. Transport concerns stay
20
+ // in the inbound adapter and persistence concerns stay in outbound adapters.
21
+ type Service struct {
22
+ repo ports.UserRepository
23
+ passwords ports.PasswordHasher
24
+ refreshTokens ports.RefreshTokenStore
25
+ oauthTransactions ports.OAuthTransactionStore
26
+ recoveryTokens ports.RecoveryTokenStore
27
+ mfa ports.MFAStore
28
+ mailer ports.AuthMailer
29
+ providers ProviderRegistry
30
+ recovery *Recovery
31
+ config AuthConfig
32
+ roles ports.RoleChecker
33
+ now func() time.Time
34
+ // go-scaffold:user-service-fields
35
+ }
36
+
37
+ // NewService is the application composition constructor. It accepts explicit
38
+ // ports and policy rather than a framework or catch-all infrastructure object.
39
+ func NewService(deps Dependencies, cfg AuthConfig) *Service {
40
+ if deps.Passwords == nil {
41
+ panic("user application: password hasher is required")
42
+ }
43
+ if cfg.JWTAccessTTL <= 0 {
44
+ cfg.JWTAccessTTL = 15 * time.Minute
45
+ }
46
+ if cfg.JWTRefreshTTL <= 0 {
47
+ cfg.JWTRefreshTTL = 12 * time.Hour
48
+ }
49
+ cfg.JWTRefreshMaxTTL = defaultDuration(cfg.JWTRefreshMaxTTL, cfg.JWTRefreshTTL)
50
+ cfg.OAuthStateTTL = defaultDuration(cfg.OAuthStateTTL, 10*time.Minute)
51
+ if cfg.MFA.RecoveryCodeCount == 0 {
52
+ cfg.MFA.RecoveryCodeCount = defaultMFACodes
53
+ }
54
+ clock := deps.Clock
55
+ if clock == nil {
56
+ clock = time.Now
57
+ }
58
+ return &Service{
59
+ repo: deps.Repository,
60
+ passwords: deps.Passwords,
61
+ refreshTokens: deps.RefreshTokens,
62
+ oauthTransactions: deps.OAuthTransactions,
63
+ recoveryTokens: deps.RecoveryTokens,
64
+ mfa: deps.MFA,
65
+ mailer: deps.Mailer,
66
+ providers: deps.Providers,
67
+ recovery: NewRecovery(deps.Repository, deps.RecoveryTokens, deps.Passwords),
68
+ config: cfg,
69
+ roles: deps.Roles,
70
+ now: clock,
71
+ // go-scaffold:user-service-init
72
+ }
73
+ }
74
+
75
+ func defaultDuration(value, fallback time.Duration) time.Duration {
76
+ if value > 0 {
77
+ return value
78
+ }
79
+ return fallback
80
+ }
81
+
82
+ func (s *Service) clock() time.Time {
83
+ if s.now == nil {
84
+ return time.Now()
85
+ }
86
+ return s.now()
87
+ }
88
+
89
+ // normalizeEmail is applied at every boundary an address enters the service
90
+ // through. The database stores the canonical lower-case representation.
91
+ func normalizeEmail(email string) string {
92
+ return strings.ToLower(strings.TrimSpace(email))
93
+ }
94
+
95
+ // SetRole validates the role against the role catalog before persisting it.
96
+ func (s *Service) SetRole(ctx context.Context, userID uuid.UUID, roleCode string) (*domain.User, error) {
97
+ u, err := s.repo.FindByID(ctx, userID)
98
+ if err != nil {
99
+ return nil, wrapFindErr(err)
100
+ }
101
+ if s.roles == nil {
102
+ return nil, fmt.Errorf("role checker is not configured")
103
+ }
104
+ exists, err := s.roles.CodeExists(ctx, roleCode)
105
+ if err != nil {
106
+ return nil, fmt.Errorf("check role code: %w", err)
107
+ }
108
+ if !exists {
109
+ return nil, errUnknownRole()
110
+ }
111
+ u.Role = roleCode
112
+ if err := s.repo.UpdateUser(ctx, u); err != nil {
113
+ return nil, fmt.Errorf("update user role: %w", err)
114
+ }
115
+ return u, nil
116
+ }
117
+
118
+ // ServicePort is the inbound application boundary. HTTP, jobs, and future
119
+ // transports depend on this capability set instead of the concrete service.
120
+ type ServicePort interface {
121
+ // go-scaffold:service-interface
122
+ Register(context.Context, RegisterInput) (*AuthResult, error)
123
+ Login(context.Context, LoginInput) (*AuthResult, error)
124
+ Refresh(context.Context, string) (*AuthResponse, error)
125
+ Logout(context.Context, string) error
126
+ LogoutAll(context.Context, uuid.UUID) error
127
+ ForgotPassword(context.Context, string) error
128
+ ResetPassword(context.Context, string, string) error
129
+ VerifyEmail(context.Context, string) error
130
+ ResendVerificationEmail(context.Context, uuid.UUID) error
131
+ BeginLogin(context.Context, string, LoginStartInput) (*Authorization, error)
132
+ ExchangeLogin(context.Context, string, LoginExchangeInput) (*AuthResult, error)
133
+ VerifyMFA(context.Context, string, string) (*AuthResponse, error)
134
+ MFAStatus(context.Context, uuid.UUID) (MFAStatus, error)
135
+ SetupMFA(context.Context, uuid.UUID) (*MFASetup, error)
136
+ ConfirmMFA(context.Context, uuid.UUID, string) ([]string, error)
137
+ DisableMFA(context.Context, uuid.UUID, string) error
138
+ Get(context.Context, uuid.UUID) (*domain.User, error)
139
+ List(context.Context, int, int) ([]domain.User, error)
140
+ SetRole(context.Context, uuid.UUID, string) (*domain.User, error)
141
+ }
142
+
143
+ var _ ServicePort = (*Service)(nil)
144
+
145
+ // go-scaffold:service-methods