@nakedev/go-scaffold 0.3.3 → 0.4.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 (119) hide show
  1. package/README.md +288 -50
  2. package/dist/commands/auth.js +53 -22
  3. package/dist/commands/config.js +50 -0
  4. package/dist/commands/create.js +32 -2
  5. package/dist/commands/generate.js +25 -2
  6. package/dist/commands/method.js +22 -7
  7. package/dist/commands/migration.js +2 -2
  8. package/dist/commands/observability.js +3 -3
  9. package/dist/commands/rbac.js +3 -3
  10. package/dist/commands/undo.js +5 -0
  11. package/dist/commands/worker.js +1 -1
  12. package/dist/index.js +186 -59
  13. package/dist/prompts/auth-wizard.js +40 -6
  14. package/dist/prompts/create-wizard.js +43 -2
  15. package/dist/prompts/generate-wizard.js +89 -9
  16. package/dist/templates/auth-manifest.js +31 -1
  17. package/dist/templates/create-manifest.js +4 -0
  18. package/dist/templates/module-manifest.js +37 -1
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/types.js +6 -0
  21. package/dist/utils/auth-patcher.js +115 -24
  22. package/dist/utils/config.js +147 -3
  23. package/dist/utils/main-patcher.js +29 -27
  24. package/dist/utils/marker-patch.js +7 -1
  25. package/dist/utils/method-patcher.js +261 -81
  26. package/dist/utils/module-profile.js +32 -0
  27. package/dist/utils/observability-patcher.js +2 -2
  28. package/dist/utils/platform-patcher.js +29 -7
  29. package/dist/utils/rbac-patcher.js +97 -75
  30. package/package.json +7 -2
  31. package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
  32. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  33. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  35. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  36. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  37. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  38. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  39. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  40. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  41. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  42. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  43. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  44. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  45. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
  46. package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
  47. package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
  48. package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
  49. package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
  50. package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
  51. package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
  52. package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
  53. package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
  54. package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
  55. package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
  56. package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
  57. package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
  58. package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
  59. package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
  60. package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
  61. package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
  62. package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
  63. package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
  64. package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
  65. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
  66. package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
  67. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
  68. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
  69. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
  70. package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
  71. package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
  72. package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
  73. package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
  74. package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
  75. package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
  76. package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
  77. package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
  78. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
  79. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
  80. package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
  81. package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
  82. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
  83. package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
  84. package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
  85. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  86. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  87. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
  88. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  89. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  90. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  91. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
  92. package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
  93. package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
  94. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
  95. package/templates/create/base/.env.example.hbs +0 -1
  96. package/templates/create/base/AGENTS.md.hbs +255 -67
  97. package/templates/create/base/Makefile.hbs +2 -1
  98. package/templates/create/base/README.md.hbs +45 -17
  99. package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
  100. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  101. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  102. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  103. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  104. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  105. package/templates/create/features/docs/architecture.md.hbs +38 -16
  106. package/templates/create/features/docs/patterns.md.hbs +40 -21
  107. package/templates/create/features/docs/techstack.md.hbs +3 -3
  108. package/templates/generate/module/commands.go.hbs +95 -0
  109. package/templates/generate/module/composition.go.hbs +23 -0
  110. package/templates/generate/module/cqrs_test.go.hbs +7 -0
  111. package/templates/generate/module/handler.go.hbs +50 -5
  112. package/templates/generate/module/minimal/commands.go.hbs +34 -0
  113. package/templates/generate/module/minimal/handler.go.hbs +34 -0
  114. package/templates/generate/module/minimal/queries.go.hbs +45 -0
  115. package/templates/generate/module/minimal/service.go.hbs +27 -1
  116. package/templates/generate/module/queries.go.hbs +62 -0
  117. package/templates/generate/module/service.go.hbs +61 -5
  118. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  119. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
@@ -78,24 +78,40 @@ function patchUserJWTForRbac(jwtGoPath) {
78
78
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:jwt-claims-values", "Role: role,", "Role: role,");
79
79
  fs_extra_1.default.writeFileSync(jwtGoPath, content);
80
80
  }
81
- // patchUserServiceForRbac wires a role.Service dependency into user.Service
82
- // (via a consumer-side roleChecker interface, same convention as
83
- // repository/mailer/tokenStore) and adds SetRole, the one place a user's
84
- // role actually changes after creation.
85
- function patchUserServiceForRbac(serviceGoPath) {
81
+ // patchUserServiceForRbac adds the RBAC behavior to user.Service. The base
82
+ // auth constructor already exposes Dependencies.Roles, so enabling RBAC does
83
+ // not mutate a positional constructor signature anymore.
84
+ function patchUserServiceForRbac(serviceGoPath, goModule, sessionsGoPath) {
86
85
  let content = fs_extra_1.default.readFileSync(serviceGoPath, "utf8");
87
86
  const roleCheckerInterface = [
88
- "// roleChecker = what the service needs from the role domain to validate a",
87
+ "// RoleChecker is what the service needs from the role domain to validate a",
89
88
  "// role assignment — declared consumer-side, same convention as repository/mailer.",
90
- "type roleChecker interface {",
89
+ "type RoleChecker interface {",
91
90
  "\tCodeExists(ctx context.Context, code string) (bool, error)",
92
91
  "}",
93
92
  ].join("\n");
94
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-interfaces", roleCheckerInterface, "type roleChecker interface");
95
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-fields", "roles roleChecker", "roles roleChecker");
96
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-params", "roles roleChecker,", "roles roleChecker,");
97
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-init", "roles: roles,", "roles: roles,");
98
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:issue-access-token-args", "u.Role,", "u.Role,");
93
+ if (!content.includes("type RoleChecker interface") && !content.includes("type roleChecker interface")) {
94
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-interfaces", roleCheckerInterface, "type RoleChecker interface");
95
+ }
96
+ // issueTokens moved out of service.go with auth's use-case split. Keep the
97
+ // patch compatible with both shapes: new projects patch sessions.go, while
98
+ // older generated projects still have the marker in service.go.
99
+ const tokenIssuePath = sessionsGoPath && fs_extra_1.default.existsSync(sessionsGoPath) ? sessionsGoPath : serviceGoPath;
100
+ let tokenIssueContent = tokenIssuePath === serviceGoPath ? content : fs_extra_1.default.readFileSync(tokenIssuePath, "utf8");
101
+ tokenIssueContent = (0, marker_patch_1.insertBeforeMarkerOnce)(tokenIssueContent, "// go-scaffold:issue-access-token-args", "u.Role,", "u.Role,");
102
+ if (tokenIssuePath === serviceGoPath) {
103
+ content = tokenIssueContent;
104
+ }
105
+ // Auth keeps its base service focused and therefore does not need RBAC's
106
+ // error/ORM imports until this optional method is patched in. Add them here
107
+ // instead of carrying unused imports in every auth-only project. The module
108
+ // argument is optional for callers that patch legacy projects whose service
109
+ // already imported apperror; new CLI calls always provide it.
110
+ if (goModule) {
111
+ content = (0, marker_patch_1.ensureImport)(content, "errors");
112
+ content = (0, marker_patch_1.ensureImport)(content, `${goModule}/internal/shared/apperror`);
113
+ content = (0, marker_patch_1.ensureImport)(content, "gorm.io/gorm");
114
+ }
99
115
  const setRoleMethod = [
100
116
  "// SetRole assigns userID a new role — validated against the role catalog",
101
117
  "// (not just the DB's FK) so a typo'd code fails with a clear 422 here",
@@ -107,33 +123,30 @@ function patchUserServiceForRbac(serviceGoPath) {
107
123
  "\t\tif errors.Is(err, gorm.ErrRecordNotFound) {",
108
124
  "\t\t\treturn nil, errNotFound()",
109
125
  "\t\t}",
110
- "\t\treturn nil, apperror.NewInternal()",
126
+ "\t\treturn nil, apperror.NewInternal(err)",
111
127
  "\t}",
112
128
  "\texists, err := s.roles.CodeExists(ctx, roleCode)",
113
129
  "\tif err != nil {",
114
- "\t\treturn nil, apperror.NewInternal()",
130
+ "\t\treturn nil, apperror.NewInternal(err)",
115
131
  "\t}",
116
132
  "\tif !exists {",
117
133
  "\t\treturn nil, errUnknownRole()",
118
134
  "\t}",
119
135
  "\tu.Role = roleCode",
120
136
  "\tif err := s.repo.UpdateUser(ctx, u); err != nil {",
121
- "\t\treturn nil, apperror.NewInternal()",
137
+ "\t\treturn nil, apperror.NewInternal(err)",
122
138
  "\t}",
123
139
  "\treturn u, nil",
124
140
  "}",
125
141
  ].join("\n");
126
142
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-methods", setRoleMethod, "func (s *Service) SetRole(");
127
143
  fs_extra_1.default.writeFileSync(serviceGoPath, content);
144
+ if (tokenIssuePath !== serviceGoPath)
145
+ fs_extra_1.default.writeFileSync(tokenIssuePath, tokenIssueContent);
128
146
  }
129
- // patchUserServiceTestForRbac keeps service_test.go's single NewService call
130
- // site (newTestService) compiling once patchUserServiceForRbac adds the
131
- // roleChecker param above same drift risk the handoff notes called out for
132
- // middleware.NewAuthz's call sites, just for this signature instead. The fake
133
- // itself is added here too, not pre-declared unconditionally in the
134
- // template: golangci-lint's unused check flags an unreferenced type+method
135
- // pair in the auth-only (pre-rbac) state, since nothing there yet implements
136
- // or needs a roleChecker.
147
+ // patchUserServiceTestForRbac supplies a fake role capability through the
148
+ // explicit Dependencies literal. The fake itself remains opt-in so auth-only
149
+ // projects do not carry an unused RBAC test double.
137
150
  function patchUserServiceTestForRbac(serviceTestGoPath) {
138
151
  let content = fs_extra_1.default.readFileSync(serviceTestGoPath, "utf8");
139
152
  const fakeRoles = [
@@ -143,7 +156,7 @@ function patchUserServiceTestForRbac(serviceTestGoPath) {
143
156
  "func (fakeRoles) CodeExists(context.Context, string) (bool, error) { return true, nil }",
144
157
  ].join("\n");
145
158
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-test-types", fakeRoles, "type fakeRoles struct{}");
146
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-test-args", "fakeRoles{},", "fakeRoles{},");
159
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-service-test-deps", "Roles: fakeRoles{},", "Roles: fakeRoles{},");
147
160
  fs_extra_1.default.writeFileSync(serviceTestGoPath, content);
148
161
  }
149
162
  // patchUserDTOForRbac adds the request DTO for PATCH /users/:id/set-role and
@@ -164,13 +177,26 @@ function patchUserDTOForRbac(dtoGoPath) {
164
177
  // /permissions for the actual role/permission management API.
165
178
  function patchUserHandlerForRbac(handlerGoPath, goModule) {
166
179
  let content = fs_extra_1.default.readFileSync(handlerGoPath, "utf8");
180
+ // The base auth handler is intentionally only the route/composition surface;
181
+ // its endpoint implementations now live in sibling files. RBAC still
182
+ // patches its admin handlers into handler.go for marker compatibility, so
183
+ // make the imports required by those injected handlers explicit here rather
184
+ // than relying on imports that used to come from the old monolithic file.
185
+ content = (0, marker_patch_1.ensureImport)(content, "net/http");
186
+ content = (0, marker_patch_1.ensureImport)(content, `${goModule}/internal/shared/httpx`);
167
187
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-consts", 'const PermUserManageRole = "user:manage-role"', "PermUserManageRole");
168
188
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-consts", 'const PermUserRead = "user:read"', "PermUserRead");
169
189
  const paginationImport = `"${goModule}/internal/shared/pagination"`;
170
190
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-imports", paginationImport, "internal/shared/pagination");
171
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-fields", "authz *middleware.Authz", "authz *middleware.Authz");
172
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-params", "authz *middleware.Authz,", "authz *middleware.Authz,");
173
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-init", "authz: authz,", "authz: authz,");
191
+ if (!/\bauthz\s+(?:\*middleware\.Authz|authorizer)\b/.test(content)) {
192
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-fields", "authz *middleware.Authz", "authz *middleware.Authz");
193
+ }
194
+ if (!/\bauthz\s+\.\.\.(?:\*middleware\.Authz|authorizer)\s*,/.test(content) && !/\bauthz\s+\*middleware\.Authz\s*,/.test(content)) {
195
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-params", "authz *middleware.Authz,", "authz *middleware.Authz,");
196
+ }
197
+ if (!/authz:\s+authzMiddleware,/.test(content) && !/authz:\s+authz,/.test(content)) {
198
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-handler-init", "authz: authz,", "authz: authz,");
199
+ }
174
200
  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)');
175
201
  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)');
176
202
  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"');
@@ -233,28 +259,24 @@ function patchUserErrorsForRbac(errorsGoPath) {
233
259
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:user-errors", errFn, "func errUnknownRole(");
234
260
  fs_extra_1.default.writeFileSync(errorsGoPath, content);
235
261
  }
236
- // patchMainGoForRbac wires the role domain into cmd/api: its import, its
237
- // three models in the AutoMigrate call, and the one place a plain insert
238
- // isn't enough REPLACES the `add auth` PR's user.NewHandler(...) call with
239
- // a version that also builds roleSvc/authz and passes them through, plus
240
- // registers role's own routes right after it.
241
- // userSvcLineFor rebuilds the exact line `add auth` wrote, from the same
242
- // helper it used. Exported so the command can check for it *before* it starts
243
- // patching: every other file rbac touches is edited first, and until this
244
- // existed a mismatch here threw after user.NewService had already grown a
245
- // roleChecker parameter — leaving a project that no longer compiled and an
246
- // error telling you to restore a line that wouldn't have fixed it.
262
+ // userSvcLineFor rebuilds the legacy line `add auth` wrote before auth got a
263
+ // feature-local composition root. It remains here so `add rbac` can upgrade an
264
+ // older generated project without pulling the old construction shape into new
265
+ // output.
247
266
  function userSvcLineFor(goModule, store, worker) {
248
267
  const { tokenStore, mailer } = (0, auth_patcher_1.authWiringLines)({ goModule, queueBackend: "river", store, worker });
249
- return `userSvc := user.NewService(user.NewRepository(db), ${tokenStore}, ${mailer}, cfg)`;
268
+ return `userSvc := user.NewService(user.NewRepository(db), ${tokenStore}, ${mailer}, cfg, application.NewProviderRegistry())`;
250
269
  }
251
270
  function assertRbacPatchable(mainGoPath, goModule, store, worker) {
252
- const expected = userSvcLineFor(goModule, store, worker);
253
- if (fs_extra_1.default.readFileSync(mainGoPath, "utf8").includes(expected))
271
+ const wiring = { goModule, queueBackend: "river", store, worker };
272
+ const content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
273
+ const expected = (0, auth_patcher_1.authHandlerLineFor)(wiring);
274
+ const legacyRoute = `user.NewHandler(userSvc, cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, cfg.CookieSameSite, ${(0, auth_patcher_1.authWiringLines)(wiring).limiter}).Register(api)`;
275
+ if (content.includes(expected) || content.includes(userSvcLineFor(goModule, store, worker)) || content.includes(legacyRoute))
254
276
  return;
255
- throw new Error("cmd/api/wiring.go's userSvc line doesn't match what `add auth` wrote, so `add rbac` can't extend it.\n" +
277
+ throw new Error("cmd/api/wiring.go's auth route doesn't match what `add auth` wrote, so `add rbac` can't extend it.\n" +
256
278
  `Expected to find:\n ${expected}\n\n` +
257
- "It was probably hand-edited. Restore that line and re-run — nothing has been changed yet.");
279
+ "It was probably hand-edited. Restore that route and re-run — nothing has been changed yet.");
258
280
  }
259
281
  function patchMainGoForRbac(mainGoPath, goModule, store, worker) {
260
282
  let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
@@ -272,37 +294,36 @@ function patchMainGoForRbac(mainGoPath, goModule, store, worker) {
272
294
  for (const line of migrateLines) {
273
295
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
274
296
  }
275
- // roleSvc and authz have to be declared *above* userSvc, which now takes
276
- // roleSvc so this rewrites the service line in place rather than
277
- // appending at the marker (which would land below it).
278
- // Rebuilt from the same helper `add auth` used, so a project on either store
279
- // gets its own line matched rather than a hardcoded guess at one of them.
280
- const { limiter } = (0, auth_patcher_1.authWiringLines)({ goModule, queueBackend: "river", store, worker });
281
- const userSvcLine = userSvcLineFor(goModule, store, worker);
282
- // Throw rather than skip: the roleSvc/authz declarations this rewrite adds
283
- // are what the unconditional patches below refer to. Skipping quietly still
284
- // emits `roleSvc`/`authz` references with nothing declaring them, so the
285
- // command reports success over a main.go that doesn't compile.
286
- if (!content.includes(userSvcLine)) {
287
- throw new Error(`cmd/api/wiring.go's userSvc line doesn't match what \`add auth\` wrote, so \`add rbac\` can't extend it.\n` +
288
- `Expected to find:\n ${userSvcLine}\n\n` +
289
- `It was probably hand-edited. Restore that line (add rbac will re-extend it), or apply the rbac wiring by hand:\n` +
290
- ` roleSvc := role.NewService(role.NewRepository(db))\n` +
291
- ` authz := middleware.NewAuthz(roleSvc.PermissionsOf, cfg.AuthzCacheTTL)\n` +
292
- ` ...then pass roleSvc as user.NewService's last argument.`);
297
+ const wiring = { goModule, queueBackend: "river", store, worker };
298
+ const authRouteLine = (0, auth_patcher_1.authHandlerLineFor)(wiring);
299
+ const roleCompositionLine = "roleComposition := role.NewCompositionFromDB(db, cfg.JWTSecret, cfg.AuthzCacheTTL)";
300
+ const roleHandlerLine = "roleComposition.Handler.Register(api)";
301
+ if (content.includes(authRouteLine)) {
302
+ const authWithRoleLine = (0, auth_patcher_1.authHandlerLineFor)(wiring, ["roleComposition.Service", "roleComposition.Authz"]);
303
+ content = content.replace(authRouteLine, [roleCompositionLine, authWithRoleLine, roleHandlerLine].join("\n"));
304
+ }
305
+ else {
306
+ // Upgrade projects generated before auth's local composition root. The
307
+ // role feature is still constructed locally; only the legacy user service
308
+ // call needs an adapter until that project is regenerated.
309
+ const applicationImportLine = `"${goModule}/internal/app/user/application"`;
310
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, applicationImportLine, applicationImportLine);
311
+ const userSvcLine = userSvcLineFor(goModule, store, worker);
312
+ if (!content.includes(userSvcLine)) {
313
+ throw new Error(`cmd/api/wiring.go's auth route doesn't match what \`add auth\` wrote, so \`add rbac\` can't extend it.\n` +
314
+ `Expected to find:\n ${authRouteLine}\n\n` +
315
+ "It was probably hand-edited. Restore that route and re-run — nothing has been changed yet.");
316
+ }
317
+ content = content.replace(userSvcLine, [roleCompositionLine, `${userSvcLine.slice(0, -1)}, roleComposition.Service)`].join("\n"));
318
+ const limiter = (0, auth_patcher_1.authWiringLines)(wiring).limiter;
319
+ const legacyRoute = `user.NewHandler(userSvc, cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, cfg.CookieSameSite, ${limiter}).Register(api)`;
320
+ const tail = ").Register(api)";
321
+ if (!content.includes(legacyRoute)) {
322
+ throw new Error("cmd/api/wiring.go is missing auth's legacy handler route while adding RBAC");
323
+ }
324
+ content = content.replace(legacyRoute, `${legacyRoute.slice(0, -tail.length)}, roleComposition.Authz${tail}`);
325
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, roleHandlerLine, roleHandlerLine);
293
326
  }
294
- content = content.replace(userSvcLine, [
295
- "roleSvc := role.NewService(role.NewRepository(db))",
296
- "authz := middleware.NewAuthz(roleSvc.PermissionsOf, cfg.AuthzCacheTTL)",
297
- `${userSvcLine.slice(0, -1)}, roleSvc)`,
298
- ].join("\n"));
299
- const userRouteLine = `user.NewHandler(userSvc, cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, cfg.CookieSameSite, ${limiter}).Register(api)`;
300
- // strip the trailing `).Register(api)` — not just `.Register(api)` — so authz
301
- // lands inside NewHandler's argument list rather than after its closing paren
302
- const tail = ").Register(api)";
303
- content = content.replace(userRouteLine, `${userRouteLine.slice(0, -tail.length)}, authz${tail}`);
304
- const roleRouteLine = "role.NewHandler(roleSvc, cfg.JWTSecret, authz).Register(api)";
305
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, roleRouteLine, roleRouteLine);
306
327
  fs_extra_1.default.writeFileSync(mainGoPath, content);
307
328
  }
308
329
  // patchCmdSeedForRbac makes the seeded admin actually an admin: wires a
@@ -313,9 +334,10 @@ function patchCmdSeedForRbac(seedMainGoPath, goModule) {
313
334
  let content = fs_extra_1.default.readFileSync(seedMainGoPath, "utf8");
314
335
  const importLine = `"${goModule}/internal/app/role"`;
315
336
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-imports", importLine, importLine);
316
- const roleSvcLine = "roleSvc := role.NewService(role.NewRepository(db))";
337
+ const roleSvcLine = "roleSvc := role.NewServiceFromDB(db)";
317
338
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-services", roleSvcLine, roleSvcLine);
318
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-user-service-args", "roleSvc,", "roleSvc,");
339
+ const seedRoleDependency = content.includes("user.Dependencies{") ? "Roles: roleSvc," : "roleSvc,";
340
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:seed-user-service-args", seedRoleDependency, seedRoleDependency);
319
341
  const setRoleCall = [
320
342
  'if _, err := svc.SetRole(ctx, u.ID, "admin"); err != nil {',
321
343
  '\tlogger.Error("promote seed admin to admin role", "error", err)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nakedev/go-scaffold",
3
- "version": "0.3.3",
3
+ "version": "0.4.3",
4
4
  "description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,9 +23,13 @@
23
23
  "test:unit": "node --test tests/unit/*.test.mjs",
24
24
  "test:integration": "node --test tests/integration/*.test.mjs",
25
25
  "test:smoke": "node scripts/smoke-test.mjs",
26
+ "test:e2e": "playwright test --config=playwright.config.mjs",
27
+ "test:e2e:install": "playwright install chromium",
26
28
  "verify": "pnpm run build && pnpm run test",
29
+ "release:check": "node scripts/check-release.mjs",
30
+ "policy:main": "node scripts/check-main-merge.mjs",
27
31
  "prepack": "rm -rf dist && pnpm run build",
28
- "prepublishOnly": "pnpm run verify"
32
+ "prepublishOnly": "pnpm run release:check && pnpm run verify"
29
33
  },
30
34
  "keywords": [
31
35
  "go",
@@ -49,6 +53,7 @@
49
53
  "pluralize": "^8.0.0"
50
54
  },
51
55
  "devDependencies": {
56
+ "@playwright/test": "1.55.0",
52
57
  "@types/fs-extra": "^11.0.4",
53
58
  "@types/node": "^24.0.0",
54
59
  "@types/pluralize": "^0.0.33",
@@ -30,10 +30,20 @@ func main() {
30
30
  }
31
31
 
32
32
  // go-scaffold:seed-services
33
- svc := user.NewService(
34
- user.NewRepository(db), nil, nil, cfg,
33
+ svc := user.NewService(user.Dependencies{
34
+ Repository: user.NewRepository(db),
35
35
  // go-scaffold:seed-user-service-args
36
- )
36
+ }, user.AuthConfig{
37
+ JWTSecret: cfg.JWTSecret,
38
+ JWTAccessTTL: cfg.JWTAccessTTL,
39
+ JWTRefreshTTL: cfg.JWTRefreshTTL,
40
+ JWTRefreshMaxTTL: cfg.JWTRefreshMaxTTL,
41
+ OAuthStateTTL: cfg.OAuthStateTTL,
42
+ PasswordResetTTL: cfg.PasswordResetTTL,
43
+ PasswordResetURL: cfg.PasswordResetURL,
44
+ EmailVerifyTTL: cfg.EmailVerifyTTL,
45
+ EmailVerifyURL: cfg.EmailVerifyURL,
46
+ })
37
47
  ctx := context.Background()
38
48
 
39
49
  if email := os.Getenv("SEED_ADMIN_EMAIL"); email != "" {
@@ -11,9 +11,19 @@ post:
11
11
  responses:
12
12
  "200":
13
13
  description: ok, refresh_token set as an httpOnly cookie
14
+ headers:
15
+ Cache-Control:
16
+ description: token responses are never cacheable
17
+ schema: { type: string, example: no-store }
18
+ Pragma:
19
+ description: legacy cache prevention for token responses
20
+ schema: { type: string, example: no-cache }
14
21
  content:
15
22
  application/json:
16
- schema: { $ref: './schemas.yaml#/AuthResponse' }
23
+ schema:
24
+ oneOf:
25
+ - { $ref: './schemas.yaml#/AuthResponse' }
26
+ - { $ref: './schemas.yaml#/MFAChallengeResponse' }
17
27
  "400": { $ref: '../common/responses.yaml#/ValidationError' }
18
28
  "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
19
29
  "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
@@ -0,0 +1,19 @@
1
+ post:
2
+ summary: Complete a login with a TOTP or recovery code
3
+ operationId: verifyMFA
4
+ tags: [auth]
5
+ security: []
6
+ requestBody:
7
+ required: true
8
+ content:
9
+ application/json:
10
+ schema: { $ref: './schemas.yaml#/MFAChallengeInput' }
11
+ responses:
12
+ "200":
13
+ description: authenticated; 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,40 @@
1
+ post:
2
+ summary: Exchange a provider authorization code for a local session
3
+ description: The frontend callback route sends the code, the same state, and the PKCE verifier to the API. The API consumes a one-time server-side state transaction bound to the provider, S256 challenge, and OIDC nonce, uses its exact provider-registered redirect URI, validates the provider response, resolves the local identity, sets an HttpOnly refresh cookie, and returns an access token. The request has no redirect_uri, return_to, or frontend destination; credentials are never placed in a URI.
4
+ operationId: providerExchange
5
+ tags: [auth]
6
+ security: []
7
+ parameters:
8
+ - name: provider
9
+ in: path
10
+ required: true
11
+ schema: { type: string, example: google }
12
+ requestBody:
13
+ required: true
14
+ content:
15
+ application/json:
16
+ schema:
17
+ $ref: "./schemas.yaml#/OAuthExchangeInput"
18
+ responses:
19
+ "200":
20
+ description: local session created; refresh token is set only as an HttpOnly cookie
21
+ headers:
22
+ Set-Cookie:
23
+ description: HttpOnly refresh cookie
24
+ schema: { type: string }
25
+ Cache-Control:
26
+ description: token responses are never cacheable
27
+ schema: { type: string, example: no-store }
28
+ Pragma:
29
+ description: legacy cache prevention for token responses
30
+ schema: { type: string, example: no-cache }
31
+ content:
32
+ application/json:
33
+ schema:
34
+ oneOf:
35
+ - { $ref: "./schemas.yaml#/AuthResponse" }
36
+ - { $ref: "./schemas.yaml#/MFAChallengeResponse" }
37
+ "400":
38
+ description: controlled OAuth error (oauth_denied, oauth_state_invalid, or oauth_failed)
39
+ "503":
40
+ description: provider is unavailable or not configured
@@ -0,0 +1,31 @@
1
+ get:
2
+ summary: Start an external OAuth login flow
3
+ description: Redirects the browser to the configured provider. The browser client supplies state and an S256 PKCE challenge; the server creates a one-time transaction bound to that state, provider, challenge, and OIDC nonce, then uses the exact provider-registered redirect URI. It never accepts a client redirect URI.
4
+ operationId: providerLogin
5
+ tags: [auth]
6
+ security: []
7
+ parameters:
8
+ - name: provider
9
+ in: path
10
+ required: true
11
+ schema: { type: string, example: google }
12
+ - name: state
13
+ in: query
14
+ required: true
15
+ schema: { type: string, minLength: 1 }
16
+ - name: code_challenge
17
+ in: query
18
+ required: true
19
+ schema:
20
+ type: string
21
+ minLength: 43
22
+ maxLength: 128
23
+ pattern: '^[A-Za-z0-9._~-]+$'
24
+ - name: code_challenge_method
25
+ in: query
26
+ required: true
27
+ schema: { type: string, enum: [S256] }
28
+ responses:
29
+ "302": { description: redirect to the provider's authorization endpoint }
30
+ "400": { description: missing or invalid state/PKCE parameters; response uses a controlled OAuth error code }
31
+ "503": { description: provider is unavailable or not configured }
@@ -9,6 +9,13 @@ post:
9
9
  responses:
10
10
  "200":
11
11
  description: ok, a new refresh_token cookie is set
12
+ headers:
13
+ Cache-Control:
14
+ description: token responses are never cacheable
15
+ schema: { type: string, example: no-store }
16
+ Pragma:
17
+ description: legacy cache prevention for token responses
18
+ schema: { type: string, example: no-cache }
12
19
  content:
13
20
  application/json:
14
21
  schema: { $ref: './schemas.yaml#/AuthResponse' }
@@ -11,6 +11,13 @@ post:
11
11
  responses:
12
12
  "201":
13
13
  description: created, refresh_token set as an httpOnly cookie
14
+ headers:
15
+ Cache-Control:
16
+ description: token responses are never cacheable
17
+ schema: { type: string, example: no-store }
18
+ Pragma:
19
+ description: legacy cache prevention for token responses
20
+ schema: { type: string, example: no-cache }
14
21
  content:
15
22
  application/json:
16
23
  schema: { $ref: './schemas.yaml#/AuthResponse' }
@@ -1,6 +1,6 @@
1
1
  post:
2
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.
3
+ description: The token is consumed only after the password update and refresh-session revocation complete. If the session store is temporarily unavailable, the internal error response restores the token so the caller can retry the complete operation.
4
4
  operationId: resetPassword
5
5
  tags: [auth]
6
6
  security: []
@@ -32,8 +32,66 @@ VerifyEmailInput:
32
32
  properties:
33
33
  token: { type: string }
34
34
 
35
+ MFAChallengeInput:
36
+ type: object
37
+ required: [challenge, code]
38
+ additionalProperties: false
39
+ properties:
40
+ challenge: { type: string, minLength: 1 }
41
+ code: { type: string, minLength: 6, maxLength: 16 }
42
+
43
+ MFACodeInput:
44
+ type: object
45
+ required: [code]
46
+ additionalProperties: false
47
+ properties:
48
+ code: { type: string, minLength: 6, maxLength: 16 }
49
+
50
+ MFAChallengeResponse:
51
+ type: object
52
+ required: [mfa_required, challenge]
53
+ properties:
54
+ mfa_required: { type: boolean, example: true }
55
+ challenge: { type: string }
56
+
57
+ MFAStatus:
58
+ type: object
59
+ required: [available, enabled]
60
+ properties:
61
+ available: { type: boolean }
62
+ enabled: { type: boolean }
63
+
64
+ MFASetupResponse:
65
+ type: object
66
+ required: [secret, otpauth_uri]
67
+ properties:
68
+ secret: { type: string, description: show once and add to an authenticator app }
69
+ otpauth_uri: { type: string, format: uri }
70
+
71
+ MFAConfirmResponse:
72
+ type: object
73
+ required: [recovery_codes]
74
+ properties:
75
+ recovery_codes:
76
+ type: array
77
+ items: { type: string }
78
+
79
+ OAuthExchangeInput:
80
+ type: object
81
+ required: [code, state, code_verifier]
82
+ additionalProperties: false
83
+ properties:
84
+ code: { type: string, minLength: 1 }
85
+ state: { type: string, minLength: 1 }
86
+ code_verifier:
87
+ type: string
88
+ minLength: 43
89
+ maxLength: 128
90
+ pattern: '^[A-Za-z0-9._~-]+$'
91
+
35
92
  # 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.
93
+ # echoed back — see toCookieResponse in internal/app/user/dto.go. All token
94
+ # responses carry Cache-Control: no-store and Pragma: no-cache.
37
95
  AuthResponse:
38
96
  type: object
39
97
  properties:
@@ -0,0 +1,19 @@
1
+ post:
2
+ summary: Confirm MFA enrollment with a current TOTP code
3
+ operationId: confirmMyMFA
4
+ tags: [users]
5
+ security: [{ bearerAuth: [] }]
6
+ requestBody:
7
+ required: true
8
+ content:
9
+ application/json:
10
+ schema: { $ref: './schemas.yaml#/MFACodeInput' }
11
+ responses:
12
+ "200":
13
+ description: MFA enabled; recovery codes are returned once
14
+ content:
15
+ application/json:
16
+ schema: { $ref: './schemas.yaml#/MFAConfirmResponse' }
17
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
18
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
19
+ "409": { $ref: '../common/responses.yaml#/ConflictError' }
@@ -0,0 +1,15 @@
1
+ post:
2
+ summary: Disable MFA for the current user
3
+ operationId: disableMyMFA
4
+ tags: [users]
5
+ security: [{ bearerAuth: [] }]
6
+ requestBody:
7
+ required: true
8
+ content:
9
+ application/json:
10
+ schema: { $ref: './schemas.yaml#/MFACodeInput' }
11
+ responses:
12
+ "204": { description: MFA disabled }
13
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
14
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
15
+ "409": { $ref: '../common/responses.yaml#/ConflictError' }
@@ -0,0 +1,14 @@
1
+ post:
2
+ summary: Start MFA enrollment for the current user
3
+ operationId: setupMyMFA
4
+ tags: [users]
5
+ security: [{ bearerAuth: [] }]
6
+ responses:
7
+ "200":
8
+ description: secret and otpauth URI; enrollment remains disabled until confirmed
9
+ content:
10
+ application/json:
11
+ schema: { $ref: './schemas.yaml#/MFASetupResponse' }
12
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
13
+ "409": { $ref: '../common/responses.yaml#/ConflictError' }
14
+ "503": { description: MFA is disabled by the operator }
@@ -0,0 +1,12 @@
1
+ get:
2
+ summary: Get the current user's MFA status
3
+ operationId: getMyMFAStatus
4
+ tags: [users]
5
+ security: [{ bearerAuth: [] }]
6
+ responses:
7
+ "200":
8
+ description: MFA availability and enrollment status
9
+ content:
10
+ application/json:
11
+ schema: { $ref: './schemas.yaml#/MFAStatus' }
12
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }