@nakedev/go-scaffold 0.1.2 → 0.1.3

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 (108) hide show
  1. package/README.md +75 -0
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +59 -1
  5. package/dist/commands/method.js +3 -0
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +19 -2
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +66 -3
  11. package/dist/prompts/create-wizard.js +6 -1
  12. package/dist/prompts/generate-wizard.js +8 -0
  13. package/dist/templates/auth-manifest.js +19 -0
  14. package/dist/templates/create-manifest.js +25 -0
  15. package/dist/templates/rbac-manifest.js +17 -0
  16. package/dist/templates/worker-manifest.js +12 -0
  17. package/dist/utils/auth-patcher.js +96 -0
  18. package/dist/utils/gocheck.js +65 -0
  19. package/dist/utils/main-patcher.js +8 -1
  20. package/dist/utils/migrations.js +30 -8
  21. package/dist/utils/openapi-patcher.js +16 -0
  22. package/dist/utils/platform-patcher.js +59 -0
  23. package/dist/utils/rbac-patcher.js +277 -0
  24. package/dist/utils/version.js +24 -0
  25. package/package.json +2 -2
  26. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  27. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  28. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  29. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  30. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  31. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  32. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  33. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  35. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  36. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  37. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  38. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  39. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  40. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  41. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  42. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  43. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  44. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  45. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  46. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  50. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  51. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  52. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  53. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  54. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  55. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  56. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  57. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  58. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  59. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  60. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  61. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  62. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  63. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  64. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  65. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  66. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  67. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  68. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  69. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  70. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  71. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  72. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  73. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  74. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  75. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  76. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  77. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  78. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  79. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  80. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  81. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  82. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  83. package/templates/create/base/.env.example.hbs +20 -0
  84. package/templates/create/base/.github/workflows/ci.yml.hbs +4 -2
  85. package/templates/create/base/.gitignore.hbs +2 -0
  86. package/templates/create/base/Makefile.hbs +30 -5
  87. package/templates/create/base/README.md.hbs +36 -8
  88. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  89. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  90. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  91. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  92. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  93. package/templates/create/base/migrations/embed.go.hbs +15 -0
  94. package/templates/create/features/docs/architecture.md.hbs +22 -0
  95. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  96. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  97. package/templates/create/features/docs/openapi.yaml.hbs +13 -0
  98. package/templates/create/features/docs/techstack.md.hbs +3 -0
  99. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  100. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  101. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  102. package/templates/generate/module/handler.go.hbs +20 -3
  103. package/templates/generate/module/handler_test.go.hbs +49 -6
  104. package/templates/generate/module/minimal/handler.go.hbs +21 -3
  105. package/templates/generate/module/minimal/handler_test.go.hbs +53 -6
  106. package/templates/generate/module/permission.down.sql.hbs +5 -0
  107. package/templates/generate/module/permission.up.sql.hbs +4 -0
  108. package/dist/utils/module-paths.js +0 -33
@@ -0,0 +1,277 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.patchAuthDocsForRbac = patchAuthDocsForRbac;
7
+ exports.patchConfigForRbac = patchConfigForRbac;
8
+ exports.patchUserModelForRbac = patchUserModelForRbac;
9
+ exports.patchMiddlewareAuthForRbac = patchMiddlewareAuthForRbac;
10
+ exports.patchUserJWTForRbac = patchUserJWTForRbac;
11
+ exports.patchUserServiceForRbac = patchUserServiceForRbac;
12
+ exports.patchUserServiceTestForRbac = patchUserServiceTestForRbac;
13
+ exports.patchUserDTOForRbac = patchUserDTOForRbac;
14
+ exports.patchUserHandlerForRbac = patchUserHandlerForRbac;
15
+ exports.patchUserErrorsForRbac = patchUserErrorsForRbac;
16
+ exports.patchMainGoForRbac = patchMainGoForRbac;
17
+ exports.patchCmdSeedForRbac = patchCmdSeedForRbac;
18
+ const fs_extra_1 = __importDefault(require("fs-extra"));
19
+ const marker_patch_1 = require("./marker-patch");
20
+ const IMPORT_MARKER = "// go-scaffold:imports";
21
+ const MODEL_MARKER = "// go-scaffold:models";
22
+ const CONFIG_FIELDS_MARKER = "// go-scaffold:config-fields";
23
+ const CONFIG_LOAD_MARKER = "// go-scaffold:config-load";
24
+ // patchAuthDocsForRbac adds `role` to the hand-written MeResponse schema in
25
+ // docs/auth/schemas.yaml — same marker convention as the Go dto.go patch
26
+ // (patchUserDTOForRbac), since GET/PATCH /users(/me) all serialize the same
27
+ // model.User whether or not RBAC is installed.
28
+ function patchAuthDocsForRbac(authSchemasYamlPath) {
29
+ if (!fs_extra_1.default.existsSync(authSchemasYamlPath))
30
+ return; // openapi docs feature disabled
31
+ let content = fs_extra_1.default.readFileSync(authSchemasYamlPath, "utf8");
32
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "# go-scaffold:me-response-fields", "role: { type: string }", "role: { type: string }");
33
+ fs_extra_1.default.writeFileSync(authSchemasYamlPath, content);
34
+ }
35
+ // patchConfigForRbac adds the Authz permission-cache TTL — configurable
36
+ // instead of the hardcoded 1 minute it started as, same marker-based
37
+ // insertion patchConfigForAuth uses.
38
+ function patchConfigForRbac(configGoPath) {
39
+ let content = fs_extra_1.default.readFileSync(configGoPath, "utf8");
40
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, "AuthzCacheTTL time.Duration", "AuthzCacheTTL time.Duration");
41
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, 'AuthzCacheTTL: time.Duration(envInt("AUTHZ_CACHE_TTL_MIN", 1)) * time.Minute,', "AuthzCacheTTL: time.Duration");
42
+ fs_extra_1.default.writeFileSync(configGoPath, content);
43
+ }
44
+ // patchUserModelForRbac adds the Role column to model.User — default 'staff'
45
+ // at the DB level (via the gorm tag), so every existing user-creation path
46
+ // (Register, Google, cmd/seed) gets a sane role without each one needing to
47
+ // set it explicitly.
48
+ function patchUserModelForRbac(userModelPath) {
49
+ let content = fs_extra_1.default.readFileSync(userModelPath, "utf8");
50
+ const field = `Role string \`json:"role" gorm:"type:varchar(20);not null;default:'staff'"\``;
51
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-fields", field, "Role string");
52
+ fs_extra_1.default.writeFileSync(userModelPath, content);
53
+ }
54
+ // patchMiddlewareAuthForRbac adds RoleKey + the Role claim to the shared
55
+ // middleware's own accessClaims copy (see auth.go's own comment on why it's
56
+ // duplicated, not imported, from the user package's claims type).
57
+ function patchMiddlewareAuthForRbac(middlewareAuthPath) {
58
+ let content = fs_extra_1.default.readFileSync(middlewareAuthPath, "utf8");
59
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:middleware-auth-keys", 'const RoleKey = "role"', 'RoleKey = "role"');
60
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:middleware-auth-claims", 'Role string `json:"role,omitempty"`', "Role string");
61
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:middleware-auth-context", "c.Set(RoleKey, claims.Role)", "c.Set(RoleKey");
62
+ fs_extra_1.default.writeFileSync(middlewareAuthPath, content);
63
+ }
64
+ // patchUserJWTForRbac adds the Role claim to the access token: the claim
65
+ // itself (accessClaims.Role), an extra issueAccessToken param, and the value
66
+ // wired into the claims literal — three separate marker points because the
67
+ // param list and the struct literal are formatted one-arg-per-line
68
+ // specifically so a marker patch can extend either without reformatting.
69
+ function patchUserJWTForRbac(jwtGoPath) {
70
+ let content = fs_extra_1.default.readFileSync(jwtGoPath, "utf8");
71
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:jwt-claims", 'Role string `json:"role,omitempty"`', "Role string");
72
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:issue-access-token-params", "role string,", "role string,");
73
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:jwt-claims-values", "Role: role,", "Role: role,");
74
+ fs_extra_1.default.writeFileSync(jwtGoPath, content);
75
+ }
76
+ // patchUserServiceForRbac wires a role.Service dependency into user.Service
77
+ // (via a consumer-side roleChecker interface, same convention as
78
+ // repository/mailer/tokenStore) and adds SetRole, the one place a user's
79
+ // role actually changes after creation.
80
+ function patchUserServiceForRbac(serviceGoPath) {
81
+ let content = fs_extra_1.default.readFileSync(serviceGoPath, "utf8");
82
+ const roleCheckerInterface = [
83
+ "// roleChecker = what the service needs from the role domain to validate a",
84
+ "// role assignment — declared consumer-side, same convention as repository/mailer.",
85
+ "type roleChecker interface {",
86
+ "\tCodeExists(ctx context.Context, code string) (bool, error)",
87
+ "}",
88
+ ].join("\n");
89
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-interfaces", roleCheckerInterface, "type roleChecker interface");
90
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-fields", "roles roleChecker", "roles roleChecker");
91
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-params", "roles roleChecker,", "roles roleChecker,");
92
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-init", "roles: roles,", "roles: roles,");
93
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:issue-access-token-args", "u.Role,", "u.Role,");
94
+ const setRoleMethod = [
95
+ "// SetRole assigns userID a new role — validated against the role catalog",
96
+ "// (not just the DB's FK) so a typo'd code fails with a clear 422 here",
97
+ "// instead of surfacing as an opaque FK-violation, and so AutoMigrate-only",
98
+ "// test schemas (which never create the FK) still reject it correctly.",
99
+ "func (s *Service) SetRole(ctx context.Context, userID uuid.UUID, roleCode string) (*model.User, error) {",
100
+ "\tu, err := s.repo.FindByID(ctx, userID)",
101
+ "\tif err != nil {",
102
+ "\t\tif errors.Is(err, gorm.ErrRecordNotFound) {",
103
+ "\t\t\treturn nil, errNotFound()",
104
+ "\t\t}",
105
+ "\t\treturn nil, apperror.NewInternal()",
106
+ "\t}",
107
+ "\texists, err := s.roles.CodeExists(ctx, roleCode)",
108
+ "\tif err != nil {",
109
+ "\t\treturn nil, apperror.NewInternal()",
110
+ "\t}",
111
+ "\tif !exists {",
112
+ "\t\treturn nil, errUnknownRole()",
113
+ "\t}",
114
+ "\tu.Role = roleCode",
115
+ "\tif err := s.repo.UpdateUser(ctx, u); err != nil {",
116
+ "\t\treturn nil, apperror.NewInternal()",
117
+ "\t}",
118
+ "\treturn u, nil",
119
+ "}",
120
+ ].join("\n");
121
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-methods", setRoleMethod, "func (s *Service) SetRole(");
122
+ fs_extra_1.default.writeFileSync(serviceGoPath, content);
123
+ }
124
+ // patchUserServiceTestForRbac keeps service_test.go's single NewService call
125
+ // site (newTestService) compiling once patchUserServiceForRbac adds the
126
+ // roleChecker param above — same drift risk the handoff notes called out for
127
+ // middleware.NewAuthz's call sites, just for this signature instead. The fake
128
+ // itself is added here too, not pre-declared unconditionally in the
129
+ // template: golangci-lint's unused check flags an unreferenced type+method
130
+ // pair in the auth-only (pre-rbac) state, since nothing there yet implements
131
+ // or needs a roleChecker.
132
+ function patchUserServiceTestForRbac(serviceTestGoPath) {
133
+ let content = fs_extra_1.default.readFileSync(serviceTestGoPath, "utf8");
134
+ const fakeRoles = [
135
+ "// fakeRoles satisfies role's roleChecker interface structurally.",
136
+ "type fakeRoles struct{}",
137
+ "",
138
+ "func (fakeRoles) CodeExists(context.Context, string) (bool, error) { return true, nil }",
139
+ ].join("\n");
140
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-test-types", fakeRoles, "type fakeRoles struct{}");
141
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-test-args", "fakeRoles{},", "fakeRoles{},");
142
+ fs_extra_1.default.writeFileSync(serviceTestGoPath, content);
143
+ }
144
+ // patchUserDTOForRbac adds the request DTO for PATCH /users/:id/set-role and
145
+ // surfaces Role on the /me response — both gated behind RBAC since
146
+ // model.User has no Role field at all without it.
147
+ function patchUserDTOForRbac(dtoGoPath) {
148
+ let content = fs_extra_1.default.readFileSync(dtoGoPath, "utf8");
149
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-me-fields", 'Role string `json:"role"`', "Role string");
150
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-me-values", "Role: u.Role,", "Role: u.Role,");
151
+ const setRoleInput = ["type setRoleInput struct {", '\tRole string `json:"role" binding:"required"`', "}"].join("\n");
152
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-dto", setRoleInput, "type setRoleInput struct");
153
+ fs_extra_1.default.writeFileSync(dtoGoPath, content);
154
+ }
155
+ // patchUserHandlerForRbac wires an *middleware.Authz into Handler and adds
156
+ // the admin routes this PR ships: listing/viewing other users (PermUserRead)
157
+ // and changing a user's role (PermUserManageRole). Suspending a user is a
158
+ // separate concern, not RBAC's — see the role domain's own /roles,
159
+ // /permissions for the actual role/permission management API.
160
+ function patchUserHandlerForRbac(handlerGoPath, goModule) {
161
+ let content = fs_extra_1.default.readFileSync(handlerGoPath, "utf8");
162
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-consts", 'const PermUserManageRole = "user:manage-role"', "PermUserManageRole");
163
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-consts", 'const PermUserRead = "user:read"', "PermUserRead");
164
+ const paginationImport = `"${goModule}/internal/shared/pagination"`;
165
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-imports", paginationImport, "internal/shared/pagination");
166
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-fields", "authz *middleware.Authz", "authz *middleware.Authz");
167
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-params", "authz *middleware.Authz,", "authz *middleware.Authz,");
168
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-init", "authz: authz,", "authz: authz,");
169
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-routes", 'usersGroup.GET("", h.authz.Require(PermUserRead), h.adminListUsers)', 'usersGroup.GET("", h.authz.Require(PermUserRead)');
170
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-routes", 'usersGroup.GET("/:id", h.authz.Require(PermUserRead), h.adminGetUser)', 'usersGroup.GET("/:id", h.authz.Require(PermUserRead)');
171
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-routes", 'usersGroup.PATCH("/:id/set-role", h.authz.Require(PermUserManageRole), h.setRole)', 'usersGroup.PATCH("/:id/set-role"');
172
+ const adminListHandler = [
173
+ "func (h *Handler) adminListUsers(c *gin.Context) {",
174
+ "\tp := pagination.Parse(c)",
175
+ "\titems, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)",
176
+ "\tif err != nil {",
177
+ "\t\tc.Error(err)",
178
+ "\t\treturn",
179
+ "\t}",
180
+ "\tout := make([]meResponse, len(items))",
181
+ "\tfor i := range items {",
182
+ "\t\tout[i] = toMeResponse(&items[i])",
183
+ "\t}",
184
+ "\tc.JSON(http.StatusOK, p.Response(out))",
185
+ "}",
186
+ ].join("\n");
187
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-funcs", adminListHandler, "func (h *Handler) adminListUsers(");
188
+ const adminGetHandler = [
189
+ "func (h *Handler) adminGetUser(c *gin.Context) {",
190
+ "\tid, ok := httpx.ParseID(c)",
191
+ "\tif !ok {",
192
+ "\t\treturn",
193
+ "\t}",
194
+ "\tu, err := h.svc.Get(c.Request.Context(), id)",
195
+ "\tif err != nil {",
196
+ "\t\tc.Error(err)",
197
+ "\t\treturn",
198
+ "\t}",
199
+ "\tc.JSON(http.StatusOK, toMeResponse(u))",
200
+ "}",
201
+ ].join("\n");
202
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-funcs", adminGetHandler, "func (h *Handler) adminGetUser(");
203
+ const setRoleHandler = [
204
+ "func (h *Handler) setRole(c *gin.Context) {",
205
+ "\tid, ok := httpx.ParseID(c)",
206
+ "\tif !ok {",
207
+ "\t\treturn",
208
+ "\t}",
209
+ "\tvar in setRoleInput",
210
+ "\tif err := c.ShouldBindJSON(&in); err != nil {",
211
+ "\t\tc.Error(httpx.BindErr(err))",
212
+ "\t\treturn",
213
+ "\t}",
214
+ "\tu, err := h.svc.SetRole(c.Request.Context(), id, in.Role)",
215
+ "\tif err != nil {",
216
+ "\t\tc.Error(err)",
217
+ "\t\treturn",
218
+ "\t}",
219
+ "\tc.JSON(http.StatusOK, toMeResponse(u))",
220
+ "}",
221
+ ].join("\n");
222
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-funcs", setRoleHandler, "func (h *Handler) setRole(");
223
+ fs_extra_1.default.writeFileSync(handlerGoPath, content);
224
+ }
225
+ function patchUserErrorsForRbac(errorsGoPath) {
226
+ let content = fs_extra_1.default.readFileSync(errorsGoPath, "utf8");
227
+ const errFn = ["func errUnknownRole() *apperror.AppError {", '\treturn apperror.New(http.StatusUnprocessableEntity, "USER_UNKNOWN_ROLE", "unknown role code")', "}"].join("\n");
228
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-errors", errFn, "func errUnknownRole(");
229
+ fs_extra_1.default.writeFileSync(errorsGoPath, content);
230
+ }
231
+ // patchMainGoForRbac wires the role domain into cmd/api: its import, its
232
+ // three models in the AutoMigrate call, and — the one place a plain insert
233
+ // isn't enough — REPLACES the `add auth` PR's user.NewHandler(...) call with
234
+ // a version that also builds roleSvc/authz and passes them through, plus
235
+ // registers role's own routes right after it.
236
+ function patchMainGoForRbac(mainGoPath, goModule) {
237
+ let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
238
+ const importLine = `"${goModule}/internal/app/role"`;
239
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
240
+ const modelImportLine = `rolemodel "${goModule}/internal/app/role/model"`;
241
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
242
+ const migrateLines = ["&rolemodel.Role{},", "&rolemodel.Permission{},", "&rolemodel.RolePermission{},"];
243
+ for (const line of migrateLines) {
244
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
245
+ }
246
+ const oldRouteLine = "user.NewHandler(user.NewService(user.NewRepository(db), user.NewRedisTokenStore(rdb), mail.NewAsyncClient(q), cfg), cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, rdb).Register(api)";
247
+ if (content.includes(oldRouteLine)) {
248
+ const newRouteBlock = [
249
+ "roleSvc := role.NewService(role.NewRepository(db))",
250
+ "authz := middleware.NewAuthz(roleSvc.PermissionsOf, cfg.AuthzCacheTTL)",
251
+ "user.NewHandler(user.NewService(user.NewRepository(db), user.NewRedisTokenStore(rdb), mail.NewAsyncClient(q), cfg, roleSvc), cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, rdb, authz).Register(api)",
252
+ "role.NewHandler(roleSvc, cfg.JWTSecret, authz).Register(api)",
253
+ ].join("\n");
254
+ content = content.replace(oldRouteLine, newRouteBlock);
255
+ }
256
+ fs_extra_1.default.writeFileSync(mainGoPath, content);
257
+ }
258
+ // patchCmdSeedForRbac makes the seeded admin actually an admin: wires a
259
+ // role.Service into cmd/seed's own user.Service, then calls SetRole right
260
+ // after EnsureUser creates/finds the SEED_ADMIN_EMAIL user (fixtures stay on
261
+ // the 'staff' DB default — only the operator-specified admin gets promoted).
262
+ function patchCmdSeedForRbac(seedMainGoPath, goModule) {
263
+ let content = fs_extra_1.default.readFileSync(seedMainGoPath, "utf8");
264
+ const importLine = `"${goModule}/internal/app/role"`;
265
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-imports", importLine, importLine);
266
+ const roleSvcLine = "roleSvc := role.NewService(role.NewRepository(db))";
267
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-services", roleSvcLine, roleSvcLine);
268
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-user-service-args", "roleSvc,", "roleSvc,");
269
+ const setRoleCall = [
270
+ 'if _, err := svc.SetRole(ctx, u.ID, "admin"); err != nil {',
271
+ '\tlogger.Error("promote seed admin to admin role", "error", err)',
272
+ "\tos.Exit(1)",
273
+ "}",
274
+ ].join("\n");
275
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-admin-role", setRoleCall, "promote seed admin to admin role");
276
+ fs_extra_1.default.writeFileSync(seedMainGoPath, content);
277
+ }
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.cliVersion = cliVersion;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const fs_extra_1 = __importDefault(require("fs-extra"));
10
+ // cliVersion reads this CLI's own version out of its package.json — one source
11
+ // of truth for `--version`, for the stamp written into a new project's
12
+ // go-scaffold.config.json, and for the drift diagnostic that compares the two.
13
+ // Same two-candidate lookup as getTemplatesRoot (dist/ when installed, src/
14
+ // when running from a checkout).
15
+ function cliVersion() {
16
+ const candidates = [
17
+ path_1.default.join(__dirname, "..", "..", "package.json"),
18
+ path_1.default.join(__dirname, "..", "..", "..", "package.json"),
19
+ ];
20
+ const resolved = candidates.find((candidate) => fs_1.default.existsSync(candidate));
21
+ if (!resolved)
22
+ throw new Error("unable to locate package.json");
23
+ return fs_extra_1.default.readJsonSync(resolved).version;
24
+ }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@nakedev/go-scaffold",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/NakePranob/go-scaffold.git"
8
8
  },
9
9
  "bin": {
10
- "go-scaffold": "./bin/go-scaffold.js"
10
+ "go-scaffold": "bin/go-scaffold.js"
11
11
  },
12
12
  "files": [
13
13
  "dist",
@@ -0,0 +1,76 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "flag"
6
+ "log/slog"
7
+ "os"
8
+
9
+ "{{goModule}}/internal/app/user"
10
+ "{{goModule}}/internal/platform/database"
11
+ "{{goModule}}/internal/shared/config"
12
+ // go-scaffold:seed-imports
13
+ )
14
+
15
+ // cmd/seed is a one-off operational task, like `migrate` — not part of the
16
+ // API server's runtime, so it's a separate binary rather than a flag on
17
+ // cmd/api. tokens/mailer are nil: EnsureUser never touches them (no login
18
+ // or email flow runs during seeding).
19
+ func main() {
20
+ withFixtures := flag.Bool("fixtures", false, "also seed sample dev users — do not use against a real environment")
21
+ flag.Parse()
22
+
23
+ cfg := config.Load()
24
+ logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
25
+
26
+ db, err := database.Open(cfg)
27
+ if err != nil {
28
+ logger.Error("open db", "error", err)
29
+ os.Exit(1)
30
+ }
31
+
32
+ // go-scaffold:seed-services
33
+ svc := user.NewService(
34
+ user.NewRepository(db), nil, nil, cfg,
35
+ // go-scaffold:seed-user-service-args
36
+ )
37
+ ctx := context.Background()
38
+
39
+ if email := os.Getenv("SEED_ADMIN_EMAIL"); email != "" {
40
+ password := os.Getenv("SEED_ADMIN_PASSWORD")
41
+ if password == "" {
42
+ logger.Error("SEED_ADMIN_PASSWORD is required when SEED_ADMIN_EMAIL is set")
43
+ os.Exit(1)
44
+ }
45
+ name := os.Getenv("SEED_ADMIN_NAME")
46
+ if name == "" {
47
+ name = "Admin"
48
+ }
49
+ u, err := svc.EnsureUser(ctx, email, password, name)
50
+ if err != nil {
51
+ logger.Error("seed admin user", "error", err)
52
+ os.Exit(1)
53
+ }
54
+ logger.Info("admin user ready", "email", u.Email, "id", u.ID)
55
+ // go-scaffold:seed-admin-role
56
+ }
57
+
58
+ if *withFixtures {
59
+ seedFixtures(ctx, svc, logger)
60
+ }
61
+ }
62
+
63
+ func seedFixtures(ctx context.Context, svc *user.Service, logger *slog.Logger) {
64
+ fixtures := []struct{ email, name string }{
65
+ {email: "dev.one@example.com", name: "Dev One"},
66
+ {email: "dev.two@example.com", name: "Dev Two"},
67
+ }
68
+ for _, f := range fixtures {
69
+ u, err := svc.EnsureUser(ctx, f.email, "password123", f.name)
70
+ if err != nil {
71
+ logger.Error("seed fixture user", "email", f.email, "error", err)
72
+ continue
73
+ }
74
+ logger.Info("fixture user ready", "email", u.Email, "id", u.ID)
75
+ }
76
+ }
@@ -0,0 +1,19 @@
1
+ post:
2
+ summary: Request a password reset link
3
+ description: Always returns the same message whether or not the email exists, to avoid leaking which emails are registered.
4
+ operationId: forgotPassword
5
+ tags: [auth]
6
+ security: []
7
+ requestBody:
8
+ required: true
9
+ content:
10
+ application/json:
11
+ schema: { $ref: './schemas.yaml#/ForgotPasswordInput' }
12
+ responses:
13
+ "200":
14
+ description: ok
15
+ content:
16
+ application/json:
17
+ schema: { $ref: './schemas.yaml#/MessageResponse' }
18
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
19
+ "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
@@ -0,0 +1,22 @@
1
+ get:
2
+ summary: Google OAuth callback
3
+ description: Redirect target Google sends the browser back to after consent — not called directly by API clients.
4
+ operationId: googleCallback
5
+ tags: [auth]
6
+ security: []
7
+ parameters:
8
+ - name: code
9
+ in: query
10
+ required: true
11
+ schema: { type: string }
12
+ - name: state
13
+ in: query
14
+ required: true
15
+ schema: { type: string }
16
+ responses:
17
+ "200":
18
+ description: ok, refresh_token set as an httpOnly cookie
19
+ content:
20
+ application/json:
21
+ schema: { $ref: './schemas.yaml#/AuthResponse' }
22
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
@@ -0,0 +1,7 @@
1
+ get:
2
+ summary: Start the Google OAuth login flow
3
+ operationId: googleLogin
4
+ tags: [auth]
5
+ security: []
6
+ responses:
7
+ "302": { description: redirect to Google's consent screen }
@@ -0,0 +1,19 @@
1
+ post:
2
+ summary: Log in with email and password
3
+ operationId: loginUser
4
+ tags: [auth]
5
+ security: []
6
+ requestBody:
7
+ required: true
8
+ content:
9
+ application/json:
10
+ schema: { $ref: './schemas.yaml#/LoginInput' }
11
+ responses:
12
+ "200":
13
+ description: ok, refresh_token set as an httpOnly cookie
14
+ content:
15
+ application/json:
16
+ schema: { $ref: './schemas.yaml#/AuthResponse' }
17
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
18
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
19
+ "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
@@ -0,0 +1,8 @@
1
+ post:
2
+ summary: Log out the current session
3
+ description: Revokes the refresh_token cookie's session and clears the cookie. Always succeeds, even with no cookie.
4
+ operationId: logoutUser
5
+ tags: [auth]
6
+ security: []
7
+ responses:
8
+ "204": { description: logged out }
@@ -0,0 +1,15 @@
1
+ post:
2
+ summary: Rotate the refresh token cookie for a new access token
3
+ description: >-
4
+ Reads refresh_token from the httpOnly cookie (no request body). Reusing an
5
+ already-rotated-out token revokes the whole session family.
6
+ operationId: refreshToken
7
+ tags: [auth]
8
+ security: []
9
+ responses:
10
+ "200":
11
+ description: ok, a new refresh_token cookie is set
12
+ content:
13
+ application/json:
14
+ schema: { $ref: './schemas.yaml#/AuthResponse' }
15
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
@@ -0,0 +1,19 @@
1
+ post:
2
+ summary: Register a new account
3
+ operationId: registerUser
4
+ tags: [auth]
5
+ security: []
6
+ requestBody:
7
+ required: true
8
+ content:
9
+ application/json:
10
+ schema: { $ref: './schemas.yaml#/RegisterInput' }
11
+ responses:
12
+ "201":
13
+ description: created, refresh_token set as an httpOnly cookie
14
+ content:
15
+ application/json:
16
+ schema: { $ref: './schemas.yaml#/AuthResponse' }
17
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
18
+ "409": { $ref: '../common/responses.yaml#/ConflictError' }
19
+ "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
@@ -0,0 +1,16 @@
1
+ post:
2
+ summary: Reset a password using a reset token
3
+ description: The token is one-time use (consumed on success) and also revokes every existing refresh token for the account.
4
+ operationId: resetPassword
5
+ tags: [auth]
6
+ security: []
7
+ requestBody:
8
+ required: true
9
+ content:
10
+ application/json:
11
+ schema: { $ref: './schemas.yaml#/ResetPasswordInput' }
12
+ responses:
13
+ "204": { description: password reset }
14
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
15
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
16
+ "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
@@ -0,0 +1,58 @@
1
+ RegisterInput:
2
+ type: object
3
+ required: [email, password, name]
4
+ properties:
5
+ email: { type: string, format: email }
6
+ password: { type: string, format: password, minLength: 8 }
7
+ name: { type: string }
8
+
9
+ LoginInput:
10
+ type: object
11
+ required: [email, password]
12
+ properties:
13
+ email: { type: string, format: email }
14
+ password: { type: string, format: password }
15
+
16
+ ForgotPasswordInput:
17
+ type: object
18
+ required: [email]
19
+ properties:
20
+ email: { type: string, format: email }
21
+
22
+ ResetPasswordInput:
23
+ type: object
24
+ required: [token, new_password]
25
+ properties:
26
+ token: { type: string }
27
+ new_password: { type: string, format: password, minLength: 8 }
28
+
29
+ VerifyEmailInput:
30
+ type: object
31
+ required: [token]
32
+ properties:
33
+ token: { type: string }
34
+
35
+ # access_token in the body; refresh_token is set as an httpOnly cookie, never
36
+ # echoed back — see toCookieResponse in internal/app/user/dto.go.
37
+ AuthResponse:
38
+ type: object
39
+ properties:
40
+ access_token: { type: string }
41
+ token_type: { type: string, example: Bearer }
42
+ expires_in: { type: integer, description: seconds until access_token expires }
43
+
44
+ MessageResponse:
45
+ type: object
46
+ properties:
47
+ message: { type: string }
48
+
49
+ MeResponse:
50
+ type: object
51
+ properties:
52
+ id: { type: string, format: uuid }
53
+ email: { type: string, format: email }
54
+ name: { type: string }
55
+ avatar_url: { type: string }
56
+ email_verified: { type: boolean }
57
+ # go-scaffold:me-response-fields
58
+ created_at: { type: string, format: date-time }
@@ -0,0 +1,9 @@
1
+ post:
2
+ summary: Log out every session for the current user
3
+ description: Revokes every refresh token for the caller, not just the current one, and clears this request's cookie too.
4
+ operationId: logoutAllSessions
5
+ tags: [users]
6
+ security: [{ bearerAuth: [] }]
7
+ responses:
8
+ "204": { description: all sessions logged out }
9
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
@@ -0,0 +1,10 @@
1
+ post:
2
+ summary: Resend the email verification link
3
+ operationId: resendVerification
4
+ tags: [users]
5
+ security: [{ bearerAuth: [] }]
6
+ responses:
7
+ "204": { description: verification email sent }
8
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
9
+ "409": { $ref: '../common/responses.yaml#/ConflictError' }
10
+ "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
@@ -0,0 +1,12 @@
1
+ get:
2
+ summary: Get the current user
3
+ operationId: getMe
4
+ tags: [users]
5
+ security: [{ bearerAuth: [] }]
6
+ responses:
7
+ "200":
8
+ description: ok
9
+ content:
10
+ application/json:
11
+ schema: { $ref: './schemas.yaml#/MeResponse' }
12
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
@@ -0,0 +1,16 @@
1
+ post:
2
+ summary: Verify an email address using a verification token
3
+ description: The token is one-time use (GETDEL), reusing an already-consumed token fails.
4
+ operationId: verifyEmail
5
+ tags: [auth]
6
+ security: []
7
+ requestBody:
8
+ required: true
9
+ content:
10
+ application/json:
11
+ schema: { $ref: './schemas.yaml#/VerifyEmailInput' }
12
+ responses:
13
+ "204": { description: email verified }
14
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
15
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
16
+ "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }