@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
@@ -1,16 +1,21 @@
1
- -- Short-lived auth tokens, for projects scaffolded with `--store postgres`.
2
- -- Only the SHA-256 hash is stored; see model/authtoken.go for what `kind`
1
+ -- Durable recovery tokens for both refresh-store choices. Refresh rotation may
2
+ -- use Redis, but password reset and email verification share this DB contract.
3
+ -- Only the SHA-256 hash is stored; see adapters/outbound/postgres/model.go for what `kind`
3
4
  -- means and why the rotation tombstone is a separate row.
4
5
  CREATE TABLE user_svc.auth_tokens (
5
6
  token_hash TEXT PRIMARY KEY,
6
7
  user_id UUID NOT NULL,
7
8
  kind VARCHAR(20) NOT NULL,
8
9
  expires_at TIMESTAMPTZ NOT NULL,
10
+ absolute_expires_at TIMESTAMPTZ,
11
+ provider VARCHAR(20) NOT NULL DEFAULT '',
12
+ code_challenge TEXT NOT NULL DEFAULT '',
13
+ nonce TEXT NOT NULL DEFAULT '',
9
14
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
10
15
  );
11
16
 
12
- -- Named to match model.AuthToken's own `index:` tags exactly GORM's
13
- -- AutoMigrate (dev) reconciles against these names, and an index the
14
- -- migration named differently is what stops the app booting.
17
+ -- Named to match model.AuthToken's own `index:` tags exactly, so the
18
+ -- development bootstrap and production migration describe the same indexes.
15
19
  CREATE INDEX idx_auth_tokens_user_kind ON user_svc.auth_tokens (user_id, kind);
16
20
  CREATE INDEX idx_auth_tokens_expires_at ON user_svc.auth_tokens (expires_at);
21
+ CREATE INDEX idx_auth_tokens_absolute_expires_at ON user_svc.auth_tokens (absolute_expires_at);
@@ -10,6 +10,6 @@ CREATE TABLE user_svc.identities (
10
10
 
11
11
  -- Named unique indexes matching model.Identity's own
12
12
  -- `uniqueIndex:idx_identities_*` tags — see create_users.up.sql for why an
13
- -- anonymous UNIQUE constraint breaks AutoMigrate.
13
+ -- anonymous UNIQUE constraint breaks schema parity.
14
14
  CREATE UNIQUE INDEX idx_identities_user_provider ON user_svc.identities (user_id, provider);
15
15
  CREATE UNIQUE INDEX idx_identities_provider_uid ON user_svc.identities (provider, provider_uid);
@@ -1,7 +1,7 @@
1
1
  -- Failed-attempt counter per account. Keyed on a hash of the submitted email
2
2
  -- so it works for addresses that have no account (otherwise throttling itself
3
3
  -- leaks which addresses are registered) and so this table is not a user list.
4
- -- See model/loginthrottle.go for why this exists separately from the rate limiter.
4
+ -- See adapters/outbound/postgres/model.go for why this exists separately from the rate limiter.
5
5
  CREATE TABLE user_svc.login_throttle (
6
6
  email_hash TEXT PRIMARY KEY,
7
7
  failures INTEGER NOT NULL DEFAULT 0,
@@ -0,0 +1,3 @@
1
+ DROP TABLE IF EXISTS user_svc.mfa_recovery_codes;
2
+ DROP TABLE IF EXISTS user_svc.mfa_challenges;
3
+ DROP TABLE IF EXISTS user_svc.mfa_enrollments;
@@ -0,0 +1,29 @@
1
+ -- MFA state is durable in Postgres for both refresh-token store choices. TOTP
2
+ -- secrets are encrypted by the application; challenges and recovery codes are
3
+ -- stored only as hashes and consumed atomically.
4
+ CREATE TABLE user_svc.mfa_enrollments (
5
+ user_id UUID PRIMARY KEY REFERENCES user_svc.users(id) ON DELETE CASCADE,
6
+ encrypted_secret TEXT NOT NULL,
7
+ enabled BOOLEAN NOT NULL DEFAULT false,
8
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
9
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
10
+ );
11
+
12
+ CREATE TABLE user_svc.mfa_challenges (
13
+ challenge_hash TEXT PRIMARY KEY,
14
+ user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
15
+ expires_at TIMESTAMPTZ NOT NULL,
16
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
17
+ );
18
+
19
+ CREATE INDEX idx_mfa_challenges_user ON user_svc.mfa_challenges (user_id);
20
+ CREATE INDEX idx_mfa_challenges_expires_at ON user_svc.mfa_challenges (expires_at);
21
+
22
+ CREATE TABLE user_svc.mfa_recovery_codes (
23
+ user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
24
+ code_hash TEXT NOT NULL,
25
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
26
+ PRIMARY KEY (user_id, code_hash)
27
+ );
28
+
29
+ CREATE INDEX idx_mfa_recovery_codes_user ON user_svc.mfa_recovery_codes (user_id);
@@ -6,14 +6,15 @@ CREATE TABLE user_svc.users (
6
6
  name VARCHAR(255) NOT NULL DEFAULT '',
7
7
  avatar_url TEXT NOT NULL DEFAULT '',
8
8
  email_verified BOOLEAN NOT NULL DEFAULT false,
9
+ role VARCHAR(20) NOT NULL DEFAULT 'staff',
9
10
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
10
11
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
11
12
  );
12
13
 
13
14
  -- A unique INDEX, not an inline UNIQUE constraint, and named to match the
14
- -- `uniqueIndex:idx_users_email` tag on model.User exactly. Postgres names an
15
- -- inline UNIQUE "users_email_key"; GORM's AutoMigrate (dev, AUTO_MIGRATE=true)
16
- -- then sees uniqueness it didn't create and tries to drop it under its own
15
+ -- `uniqueIndex:idx_users_email` tag on the outbound persistence model exactly. Postgres names an
16
+ -- inline UNIQUE "users_email_key"; the development bootstrap then sees
17
+ -- uniqueness it didn't create and tries to drop it under its own
17
18
  -- name, which fails the boot with
18
19
  -- constraint "uni_users_email" of relation "users" does not exist
19
20
  -- for anyone who ran `make migrate-up` first — which `add rbac` tells you to.
@@ -0,0 +1,142 @@
1
+ // Package httpadapter is role's inbound HTTP boundary. It owns route,
2
+ // authentication middleware, request binding, response DTOs, and translation
3
+ // from application errors to the API error envelope.
4
+ package httpadapter
5
+
6
+ import (
7
+ "errors"
8
+ "net/http"
9
+
10
+ "{{goModule}}/internal/app/role/application"
11
+ "{{goModule}}/internal/app/role/domain"
12
+ "{{goModule}}/internal/shared/apperror"
13
+ "{{goModule}}/internal/shared/httpx"
14
+ "{{goModule}}/internal/shared/middleware"
15
+ "{{goModule}}/internal/shared/pagination"
16
+
17
+ "github.com/gin-gonic/gin"
18
+ )
19
+
20
+ type Handler struct {
21
+ svc application.ServicePort
22
+ jwtSecret string
23
+ authz *middleware.Authz
24
+ }
25
+
26
+ func NewHandler(svc application.ServicePort, jwtSecret string, authz *middleware.Authz) *Handler {
27
+ return &Handler{svc: svc, jwtSecret: jwtSecret, authz: authz}
28
+ }
29
+
30
+ func (h *Handler) Register(rg gin.IRouter) {
31
+ roles := rg.Group("/roles", middleware.RequireAuth(h.jwtSecret), h.authz.Require(application.PermRoleManage))
32
+ roles.GET("", h.list)
33
+ roles.POST("", h.create)
34
+ roles.PATCH("/:code/permissions", h.setPermissions)
35
+ roles.DELETE("/:code", h.delete)
36
+
37
+ perms := rg.Group("/permissions", middleware.RequireAuth(h.jwtSecret), h.authz.Require(application.PermRoleManage))
38
+ perms.GET("", h.listPermissions)
39
+ }
40
+
41
+ func (h *Handler) list(c *gin.Context) {
42
+ p := pagination.Parse(c)
43
+ items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
44
+ if err != nil {
45
+ c.Error(toHTTPError(err))
46
+ return
47
+ }
48
+ out := make([]application.RoleResponse, len(items))
49
+ for i := range items {
50
+ out[i] = application.ToRoleResponse(items[i])
51
+ }
52
+ c.JSON(http.StatusOK, p.Response(out))
53
+ }
54
+
55
+ func (h *Handler) create(c *gin.Context) {
56
+ var in application.CreateInput
57
+ if err := c.ShouldBindJSON(&in); err != nil {
58
+ c.Error(httpx.BindErr(err))
59
+ return
60
+ }
61
+ item, err := h.svc.Create(c.Request.Context(), in)
62
+ if err != nil {
63
+ c.Error(toHTTPError(err))
64
+ return
65
+ }
66
+ c.JSON(http.StatusCreated, application.ToRoleResponse(*item))
67
+ }
68
+
69
+ func (h *Handler) setPermissions(c *gin.Context) {
70
+ var in application.SetPermissionsInput
71
+ if err := c.ShouldBindJSON(&in); err != nil {
72
+ c.Error(httpx.BindErr(err))
73
+ return
74
+ }
75
+ if in.PermissionCodes == nil {
76
+ c.Error(apperror.NewValidation("invalid role input", map[string]string{"permission_codes": "is required"}))
77
+ return
78
+ }
79
+ item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), *in.PermissionCodes)
80
+ if err != nil {
81
+ c.Error(toHTTPError(err))
82
+ return
83
+ }
84
+ h.authz.Invalidate(item.Role.Code)
85
+ c.JSON(http.StatusOK, application.ToRoleResponse(*item))
86
+ }
87
+
88
+ func (h *Handler) delete(c *gin.Context) {
89
+ if err := h.svc.Delete(c.Request.Context(), c.Param("code")); err != nil {
90
+ c.Error(toHTTPError(err))
91
+ return
92
+ }
93
+ h.authz.Invalidate(c.Param("code"))
94
+ c.Status(http.StatusNoContent)
95
+ }
96
+
97
+ func (h *Handler) listPermissions(c *gin.Context) {
98
+ p := pagination.Parse(c)
99
+ items, err := h.svc.ListPermissions(c.Request.Context(), p.Limit, p.Offset)
100
+ if err != nil {
101
+ c.Error(toHTTPError(err))
102
+ return
103
+ }
104
+ out := make([]application.PermissionResponse, len(items))
105
+ for i := range items {
106
+ out[i] = application.ToPermissionResponse(items[i])
107
+ }
108
+ c.JSON(http.StatusOK, p.Response(out))
109
+ }
110
+
111
+ func toHTTPError(err error) error {
112
+ if err == nil {
113
+ return nil
114
+ }
115
+ var appErr *apperror.AppError
116
+ if errors.As(err, &appErr) {
117
+ return err
118
+ }
119
+ var rule *domain.RuleError
120
+ if errors.As(err, &rule) {
121
+ status := http.StatusUnprocessableEntity
122
+ switch {
123
+ case errors.Is(err, domain.ErrNotFound):
124
+ status = http.StatusNotFound
125
+ case errors.Is(err, domain.ErrConflict), errors.Is(err, domain.ErrHasReferences), errors.Is(err, domain.ErrSystemRole), errors.Is(err, domain.ErrLastRoleManager):
126
+ status = http.StatusConflict
127
+ }
128
+ return apperror.New(status, rule.Code, rule.Message)
129
+ }
130
+ switch {
131
+ case errors.Is(err, domain.ErrNotFound):
132
+ return apperror.New(http.StatusNotFound, "ROLE_NOT_FOUND", "role not found")
133
+ case errors.Is(err, domain.ErrConflict):
134
+ return apperror.New(http.StatusConflict, "ROLE_CONFLICT", "role conflict")
135
+ case errors.Is(err, domain.ErrHasReferences):
136
+ return apperror.New(http.StatusConflict, "ROLE_HAS_REFERENCES", "role still has users assigned to it")
137
+ case errors.Is(err, domain.ErrUnknownPermission):
138
+ return apperror.New(http.StatusUnprocessableEntity, "ROLE_UNKNOWN_PERMISSION", "one or more permission codes do not exist")
139
+ default:
140
+ return apperror.NewInternal(err)
141
+ }
142
+ }
@@ -0,0 +1,19 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "context"
5
+ "testing"
6
+ "time"
7
+
8
+ "{{goModule}}/internal/shared/middleware"
9
+
10
+ "github.com/gin-gonic/gin"
11
+ )
12
+
13
+ func TestHandler_Register(t *testing.T) {
14
+ gin.SetMode(gin.TestMode)
15
+ authz := middleware.NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
16
+ return map[string]struct{}{}, nil
17
+ }, time.Minute)
18
+ NewHandler(nil, "", authz).Register(gin.New())
19
+ }
@@ -0,0 +1,48 @@
1
+ // Package postgres contains role persistence records. They are deliberately
2
+ // separate from role/domain so schema tags cannot leak into use cases.
3
+ package postgres
4
+
5
+ import (
6
+ "time"
7
+
8
+ "{{goModule}}/internal/app/role/domain"
9
+ )
10
+
11
+ type Role struct {
12
+ Code string `gorm:"type:varchar(20);primaryKey"`
13
+ Name string `gorm:"not null"`
14
+ IsSystem bool `gorm:"not null;default:false"`
15
+ CreatedAt time.Time
16
+ UpdatedAt time.Time
17
+ }
18
+
19
+ func (Role) TableName() string { return "role_svc.roles" }
20
+
21
+ type Permission struct {
22
+ Code string `gorm:"type:varchar(50);primaryKey"`
23
+ Description string `gorm:"not null"`
24
+ }
25
+
26
+ func (Permission) TableName() string { return "role_svc.permissions" }
27
+
28
+ type RolePermission struct {
29
+ RoleCode string `gorm:"primaryKey;type:varchar(20)"`
30
+ PermissionCode string `gorm:"primaryKey;type:varchar(50)"`
31
+ }
32
+
33
+ func (RolePermission) TableName() string { return "role_svc.role_permissions" }
34
+
35
+ func toDomainRole(row *Role) domain.Role {
36
+ return domain.Role{Code: row.Code, Name: row.Name, IsSystem: row.IsSystem, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
37
+ }
38
+
39
+ func fromDomainRole(value *domain.Role) *Role {
40
+ if value == nil {
41
+ return nil
42
+ }
43
+ return &Role{Code: value.Code, Name: value.Name, IsSystem: value.IsSystem, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt}
44
+ }
45
+
46
+ func toDomainPermission(row *Permission) domain.Permission {
47
+ return domain.Permission{Code: row.Code, Description: row.Description}
48
+ }
@@ -0,0 +1,127 @@
1
+ package postgres
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+
7
+ "{{goModule}}/internal/app/role/domain"
8
+ "{{goModule}}/internal/app/role/ports"
9
+ "{{goModule}}/internal/shared/dberr"
10
+ "{{goModule}}/internal/shared/tx"
11
+
12
+ "gorm.io/gorm"
13
+ )
14
+
15
+ var _ ports.Repository = (*Repository)(nil)
16
+
17
+ type Repository struct {
18
+ db *gorm.DB
19
+ }
20
+
21
+ func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} }
22
+
23
+ func (r *Repository) PermissionCodes(ctx context.Context, roleCode string) ([]string, error) {
24
+ var codes []string
25
+ err := tx.From(ctx, r.db).WithContext(ctx).
26
+ Model(&RolePermission{}).Where("role_code = ?", roleCode).Pluck("permission_code", &codes).Error
27
+ return codes, mapDatabaseError(err)
28
+ }
29
+
30
+ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.Role, error) {
31
+ var rows []Role
32
+ err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&rows).Error
33
+ if err != nil {
34
+ return nil, mapDatabaseError(err)
35
+ }
36
+ items := make([]domain.Role, len(rows))
37
+ for i := range rows {
38
+ items[i] = toDomainRole(&rows[i])
39
+ }
40
+ return items, nil
41
+ }
42
+
43
+ func (r *Repository) FindByCode(ctx context.Context, code string) (*domain.Role, error) {
44
+ var row Role
45
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&row, "code = ?", code).Error; err != nil {
46
+ return nil, mapDatabaseError(err)
47
+ }
48
+ value := toDomainRole(&row)
49
+ return &value, nil
50
+ }
51
+
52
+ func (r *Repository) Create(ctx context.Context, role *domain.Role) error {
53
+ return mapDatabaseError(tx.From(ctx, r.db).WithContext(ctx).Create(fromDomainRole(role)).Error)
54
+ }
55
+
56
+ func (r *Repository) SetPermissions(ctx context.Context, roleCode string, permissionCodes []string) error {
57
+ return tx.Do(ctx, r.db, func(txctx context.Context) error {
58
+ db := tx.From(txctx, r.db).WithContext(txctx)
59
+ if err := db.Where("role_code = ?", roleCode).Delete(&RolePermission{}).Error; err != nil {
60
+ return mapDatabaseError(err)
61
+ }
62
+ if len(permissionCodes) == 0 {
63
+ return nil
64
+ }
65
+ grants := make([]RolePermission, len(permissionCodes))
66
+ for i, code := range permissionCodes {
67
+ grants[i] = RolePermission{RoleCode: roleCode, PermissionCode: code}
68
+ }
69
+ return mapDatabaseError(db.Create(&grants).Error)
70
+ })
71
+ }
72
+
73
+ func (r *Repository) Delete(ctx context.Context, code string) error {
74
+ result := tx.From(ctx, r.db).WithContext(ctx).Where("code = ?", code).Delete(&Role{})
75
+ if result.Error != nil {
76
+ if dberr.IsForeignKey(result.Error) {
77
+ return domain.ErrHasReferences
78
+ }
79
+ return mapDatabaseError(result.Error)
80
+ }
81
+ if result.RowsAffected == 0 {
82
+ return domain.ErrNotFound
83
+ }
84
+ return nil
85
+ }
86
+
87
+ func (r *Repository) FindAllPermissions(ctx context.Context, limit, offset int) ([]domain.Permission, error) {
88
+ var rows []Permission
89
+ err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&rows).Error
90
+ if err != nil {
91
+ return nil, mapDatabaseError(err)
92
+ }
93
+ items := make([]domain.Permission, len(rows))
94
+ for i := range rows {
95
+ items[i] = toDomainPermission(&rows[i])
96
+ }
97
+ return items, nil
98
+ }
99
+
100
+ func (r *Repository) AllPermissionCodes(ctx context.Context) ([]string, error) {
101
+ var codes []string
102
+ err := tx.From(ctx, r.db).WithContext(ctx).Model(&Permission{}).Pluck("code", &codes).Error
103
+ return codes, mapDatabaseError(err)
104
+ }
105
+
106
+ func (r *Repository) CountRolesWithPermission(ctx context.Context, permissionCode, excludeCode string) (int64, error) {
107
+ var count int64
108
+ err := tx.From(ctx, r.db).WithContext(ctx).Model(&RolePermission{}).
109
+ Where("permission_code = ? AND role_code <> ?", permissionCode, excludeCode).Count(&count).Error
110
+ return count, mapDatabaseError(err)
111
+ }
112
+
113
+ func mapDatabaseError(err error) error {
114
+ if err == nil {
115
+ return nil
116
+ }
117
+ if errors.Is(err, gorm.ErrRecordNotFound) {
118
+ return domain.ErrNotFound
119
+ }
120
+ if dberr.IsDuplicate(err) {
121
+ return domain.ErrConflict
122
+ }
123
+ if dberr.IsForeignKey(err) {
124
+ return domain.ErrUnknownPermission
125
+ }
126
+ return err
127
+ }
@@ -1,11 +1,11 @@
1
- package role
1
+ package postgres
2
2
 
3
3
  import (
4
4
  "context"
5
5
  "os"
6
6
  "testing"
7
7
 
8
- "{{goModule}}/internal/app/role/model"
8
+ "{{goModule}}/internal/app/role/domain"
9
9
 
10
10
  "github.com/google/uuid"
11
11
  "gorm.io/driver/postgres"
@@ -67,11 +67,11 @@ func testCode(t *testing.T) string {
67
67
  }
68
68
 
69
69
  // seedPermission inserts directly rather than through Repository, which has
70
- // no create method for Permission on purpose (see model/permission.go.hbs:
70
+ // no create method for Permission on purpose (see adapters/outbound/postgres/model.go:
71
71
  // "seeded by migration, not admin-created"). Tests still need rows to grant.
72
72
  func seedPermission(t *testing.T, tx *gorm.DB, code string) {
73
73
  t.Helper()
74
- if err := tx.WithContext(context.Background()).Create(&model.Permission{Code: code, Description: code}).Error; err != nil {
74
+ if err := tx.WithContext(context.Background()).Create(&Permission{Code: code, Description: code}).Error; err != nil {
75
75
  t.Fatalf("seed permission %s: %v", code, err)
76
76
  }
77
77
  }
@@ -87,7 +87,7 @@ func TestRepository_SetPermissions_ReplacesEntireSet(t *testing.T) {
87
87
 
88
88
  roleCode := testCode(t)
89
89
  permA, permB, permC := testCode(t), testCode(t), testCode(t)
90
- if err := repo.Create(ctx, &model.Role{Code: roleCode, Name: roleCode}); err != nil {
90
+ if err := repo.Create(ctx, &domain.Role{Code: roleCode, Name: roleCode}); err != nil {
91
91
  t.Fatalf("seed role: %v", err)
92
92
  }
93
93
  seedPermission(t, tx, permA)
@@ -121,7 +121,7 @@ func TestRepository_Delete_CascadesRolePermissions(t *testing.T) {
121
121
 
122
122
  roleCode := testCode(t)
123
123
  permCode := testCode(t)
124
- if err := repo.Create(ctx, &model.Role{Code: roleCode, Name: roleCode}); err != nil {
124
+ if err := repo.Create(ctx, &domain.Role{Code: roleCode, Name: roleCode}); err != nil {
125
125
  t.Fatalf("seed role: %v", err)
126
126
  }
127
127
  seedPermission(t, tx, permCode)
@@ -152,10 +152,10 @@ func TestRepository_CountRolesWithPermission_ExcludesGivenRole(t *testing.T) {
152
152
 
153
153
  roleA, roleB := testCode(t), testCode(t)
154
154
  permCode := testCode(t)
155
- if err := repo.Create(ctx, &model.Role{Code: roleA, Name: roleA}); err != nil {
155
+ if err := repo.Create(ctx, &domain.Role{Code: roleA, Name: roleA}); err != nil {
156
156
  t.Fatalf("seed role a: %v", err)
157
157
  }
158
- if err := repo.Create(ctx, &model.Role{Code: roleB, Name: roleB}); err != nil {
158
+ if err := repo.Create(ctx, &domain.Role{Code: roleB, Name: roleB}); err != nil {
159
159
  t.Fatalf("seed role b: %v", err)
160
160
  }
161
161
  seedPermission(t, tx, permCode)
@@ -0,0 +1,47 @@
1
+ package application
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/role/domain"
7
+ )
8
+
9
+ const PermRoleManage = "role:manage"
10
+
11
+ type CreateInput struct {
12
+ Code string `json:"code"`
13
+ Name string `json:"name"`
14
+ }
15
+
16
+ type SetPermissionsInput struct {
17
+ PermissionCodes *[]string `json:"permission_codes"`
18
+ }
19
+
20
+ type RoleListItem struct {
21
+ Role domain.Role
22
+ Permissions []string
23
+ }
24
+
25
+ type RoleResponse struct {
26
+ Code string `json:"code"`
27
+ Name string `json:"name"`
28
+ IsSystem bool `json:"is_system"`
29
+ Permissions []string `json:"permissions"`
30
+ CreatedAt time.Time `json:"created_at"`
31
+ }
32
+
33
+ type PermissionResponse struct {
34
+ Code string `json:"code"`
35
+ Description string `json:"description"`
36
+ }
37
+
38
+ func ToRoleResponse(item RoleListItem) RoleResponse {
39
+ return RoleResponse{
40
+ Code: item.Role.Code, Name: item.Role.Name, IsSystem: item.Role.IsSystem,
41
+ Permissions: item.Permissions, CreatedAt: item.Role.CreatedAt,
42
+ }
43
+ }
44
+
45
+ func ToPermissionResponse(item domain.Permission) PermissionResponse {
46
+ return PermissionResponse{Code: item.Code, Description: item.Description}
47
+ }
@@ -0,0 +1,19 @@
1
+ package application
2
+
3
+ import "{{goModule}}/internal/app/role/domain"
4
+
5
+ func errInvalidCode() error {
6
+ return domain.Rule("ROLE_INVALID_CODE", "code must start with a lowercase letter and contain only lowercase letters, digits, or underscores", domain.ErrInvalidCode)
7
+ }
8
+
9
+ func errSystemRole() error {
10
+ return domain.Rule("ROLE_IS_SYSTEM", "staff/admin are system roles and cannot be deleted", domain.ErrSystemRole)
11
+ }
12
+
13
+ func errUnknownPermission() error {
14
+ return domain.Rule("ROLE_UNKNOWN_PERMISSION", "one or more permission codes do not exist", domain.ErrUnknownPermission)
15
+ }
16
+
17
+ func errLastRoleManager() error {
18
+ return domain.Rule("ROLE_LAST_MANAGER", "this is the only role that can manage roles/permissions — grant "+PermRoleManage+" to another role first", domain.ErrLastRoleManager)
19
+ }