@nakedev/go-scaffold 0.1.4 → 0.3.1

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -44
  3. package/dist/commands/auth.js +116 -11
  4. package/dist/commands/create.js +13 -1
  5. package/dist/commands/generate.js +21 -11
  6. package/dist/commands/method.js +32 -3
  7. package/dist/commands/observability.js +114 -0
  8. package/dist/commands/rbac.js +19 -2
  9. package/dist/commands/undo.js +331 -0
  10. package/dist/commands/worker.js +92 -32
  11. package/dist/index.js +366 -63
  12. package/dist/prompts/auth-wizard.js +29 -0
  13. package/dist/prompts/create-wizard.js +5 -2
  14. package/dist/prompts/generate-wizard.js +57 -0
  15. package/dist/prompts/worker-wizard.js +25 -0
  16. package/dist/templates/auth-manifest.js +20 -3
  17. package/dist/templates/create-manifest.js +13 -20
  18. package/dist/templates/observability-manifest.js +24 -0
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/templates/worker-manifest.js +23 -6
  21. package/dist/utils/auth-patcher.js +96 -21
  22. package/dist/utils/config.js +58 -10
  23. package/dist/utils/gocheck.js +57 -5
  24. package/dist/utils/golangci-patcher.js +73 -0
  25. package/dist/utils/gomod-patcher.js +53 -0
  26. package/dist/utils/main-patcher.js +58 -4
  27. package/dist/utils/marker-patch.js +125 -3
  28. package/dist/utils/method-patcher.js +17 -2
  29. package/dist/utils/module-location.js +37 -1
  30. package/dist/utils/naming.js +50 -2
  31. package/dist/utils/observability-patcher.js +107 -0
  32. package/dist/utils/platform-patcher.js +98 -12
  33. package/dist/utils/rbac-patcher.js +60 -10
  34. package/package.json +3 -5
  35. package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
  36. package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
  37. package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
  38. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
  39. package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
  40. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
  41. package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
  42. package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
  43. package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
  44. package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
  45. package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
  46. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
  47. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
  48. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
  49. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
  50. package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
  51. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
  52. package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
  53. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
  54. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
  55. package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
  56. package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
  57. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  58. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
  59. package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
  60. package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
  61. package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
  62. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
  63. package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
  64. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
  65. package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
  66. package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
  67. package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
  68. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
  69. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
  70. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
  71. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
  72. package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
  73. package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
  74. package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
  75. package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
  76. package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
  77. package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
  78. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +71 -12
  79. package/templates/create/base/.dockerignore.hbs +13 -0
  80. package/templates/create/base/.env.example.hbs +18 -9
  81. package/templates/create/base/.github/dependabot.yml.hbs +20 -0
  82. package/templates/create/base/.github/workflows/ci.yml.hbs +12 -2
  83. package/templates/create/base/.golangci.yml.hbs +27 -0
  84. package/templates/create/base/AGENTS.md.hbs +21 -7
  85. package/templates/create/base/Dockerfile.hbs +42 -0
  86. package/templates/create/base/Makefile.hbs +43 -13
  87. package/templates/create/base/README.md.hbs +45 -8
  88. package/templates/create/base/cmd/api/main.go.hbs +17 -130
  89. package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
  90. package/templates/create/base/go.mod.hbs +4 -4
  91. package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
  92. package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
  93. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
  94. package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
  95. package/templates/create/base/redocly.yaml.hbs +21 -0
  96. package/templates/create/features/docs/architecture.md.hbs +32 -11
  97. package/templates/create/features/docs/openapi.yaml.hbs +0 -4
  98. package/templates/create/features/docs/patterns.md.hbs +82 -8
  99. package/templates/create/features/docs/techstack.md.hbs +8 -3
  100. package/templates/generate/module/dto.go.hbs +8 -1
  101. package/templates/generate/module/errors.go.hbs +5 -0
  102. package/templates/generate/module/field-column.down.sql.hbs +2 -0
  103. package/templates/generate/module/field-column.up.sql.hbs +15 -0
  104. package/templates/generate/module/handler_test.go.hbs +8 -1
  105. package/templates/generate/module/migration.down.sql.hbs +3 -1
  106. package/templates/generate/module/migration.up.sql.hbs +7 -2
  107. package/templates/generate/module/minimal/dto.go.hbs +3 -1
  108. package/templates/generate/module/model/model.go.hbs +10 -1
  109. package/templates/generate/module/permission.up.sql.hbs +3 -1
  110. package/templates/generate/module/repository.go.hbs +60 -6
  111. package/templates/generate/module/repository_test.go.hbs +30 -0
  112. package/templates/generate/module/service.go.hbs +10 -1
  113. package/templates/generate/module/service_test.go.hbs +45 -0
  114. package/dist/commands/remove.js +0 -88
  115. package/scripts/smoke-test.mjs +0 -2058
  116. package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
  117. package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
  118. package/tests/integration/default-module.test.mjs +0 -46
  119. package/tests/integration/generator-naming.test.mjs +0 -81
  120. package/tests/integration/generator-unit-test-seams.test.mjs +0 -91
  121. package/tests/integration/legacy-method-compat.test.mjs +0 -222
  122. package/tests/integration/remove-module.test.mjs +0 -58
  123. package/tests/unit/naming.test.mjs +0 -94
  124. package/tests/unit/smoke-isolation.test.mjs +0 -35
@@ -3,7 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.patchComposeForRedis = patchComposeForRedis;
7
+ exports.patchCiForRedis = patchCiForRedis;
8
+ exports.patchConfigForRedis = patchConfigForRedis;
6
9
  exports.patchConfigForWorker = patchConfigForWorker;
10
+ exports.patchConfigForSMTP = patchConfigForSMTP;
7
11
  exports.patchMainGoForWorker = patchMainGoForWorker;
8
12
  const fs_extra_1 = __importDefault(require("fs-extra"));
9
13
  const marker_patch_1 = require("./marker-patch");
@@ -13,25 +17,107 @@ const CONFIG_LOAD_MARKER = "// go-scaffold:config-load";
13
17
  const PLATFORM_INIT_MARKER = "// go-scaffold:platform-init";
14
18
  const READYZ_MARKER = "// go-scaffold:readyz-checks";
15
19
  const SHUTDOWN_MARKER = "// go-scaffold:shutdown";
16
- // patchConfigForWorker adds RedisURL + SMTP_* fields to Config and their
17
- // env() loads to Load() via marker comments, the same text-insertion
18
- // approach as patchMainGo, since config.go.hbs is only rendered once (at
19
- // `create`) and everything after that is a real file a human may have
20
- // already edited.
21
- function patchConfigForWorker(configGoPath) {
20
+ // patchComposeForRedis adds a redis service to docker-compose.yml. Whatever
21
+ // pulls Redis in`add worker --queue redis`, or `add auth`'s refresh-token
22
+ // store on top of a Postgres queue makes cmd/api call cache.Open and adds a
23
+ // Redis ping to /readyz. Leaving compose Postgres-only meant the documented
24
+ // path (`make docker-up` then `make run`) produced a permanent 503, which is
25
+ // a rough first five minutes with a brand new project.
26
+ //
27
+ // No-op when the project was scaffolded with --no-docker.
28
+ function patchComposeForRedis(composePath) {
29
+ if (!fs_extra_1.default.existsSync(composePath))
30
+ return;
31
+ const content = fs_extra_1.default.readFileSync(composePath, "utf8");
32
+ if (/^\s{2}redis:/m.test(content))
33
+ return;
34
+ const service = [
35
+ " redis:",
36
+ " image: redis:7-alpine",
37
+ " ports:",
38
+ ' - "6379:6379"',
39
+ ].join("\n");
40
+ // before the top-level `volumes:` key, so redis stays inside `services:`
41
+ const out = content.includes("\nvolumes:")
42
+ ? content.replace("\nvolumes:", () => `\n${service}\n\nvolumes:`)
43
+ : content.replace(/\n?$/, "\n") + `\n${service}\n`;
44
+ fs_extra_1.default.writeFileSync(composePath, out);
45
+ }
46
+ // patchCiForRedis adds a redis service to .github/workflows/ci.yml, for the
47
+ // same reason patchComposeForRedis exists: once cmd/api calls cache.Open, a
48
+ // test that touches the token store has nothing to connect to on a runner
49
+ // whose only service is Postgres. The generated workflow was written at
50
+ // `create` time, before anything needed Redis, and nothing patched it after.
51
+ //
52
+ // No-op when the workflow was deleted or replaced by hand.
53
+ function patchCiForRedis(ciPath) {
54
+ if (!fs_extra_1.default.existsSync(ciPath))
55
+ return;
56
+ const content = fs_extra_1.default.readFileSync(ciPath, "utf8");
57
+ if (/^\s{6}redis:/m.test(content))
58
+ return;
59
+ // `steps:` sits one level under the job, so it's the first line that ends
60
+ // the `services:` block — insert the service just above it.
61
+ const stepsLine = content.split("\n").find((l) => l.trimEnd() === " steps:");
62
+ if (!stepsLine)
63
+ return;
64
+ const service = [
65
+ " redis:",
66
+ " image: redis:7-alpine",
67
+ " ports:",
68
+ " - 6379:6379",
69
+ " options: >-",
70
+ ' --health-cmd "redis-cli ping"',
71
+ " --health-interval 10s",
72
+ " --health-timeout 5s",
73
+ " --health-retries 5",
74
+ "",
75
+ ].join("\n");
76
+ fs_extra_1.default.writeFileSync(ciPath, content.replace(stepsLine, () => `${service}\n${stepsLine}`));
77
+ }
78
+ // patchConfigForRedis adds RedisURL to Config and its env() load to Load().
79
+ // Split out from the worker patch because Redis is no longer the worker's
80
+ // concern by default — `add auth` needs it for the refresh-token store even
81
+ // when the queue lives in Postgres.
82
+ function patchConfigForRedis(configGoPath) {
22
83
  let content = fs_extra_1.default.readFileSync(configGoPath, "utf8");
23
- const fieldsBlock = ["RedisURL string", "", "SMTPHost string", "SMTPPort string", "SMTPUsername string", "SMTPPassword string", "SMTPFrom string"].join("\n");
24
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, fieldsBlock, "RedisURL string");
84
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, "RedisURL string", "RedisURL");
85
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, 'RedisURL: env("REDIS_URL", "redis://localhost:6379/0"),', 'env("REDIS_URL"');
86
+ fs_extra_1.default.writeFileSync(configGoPath, content);
87
+ }
88
+ // patchConfigForWorker adds the SMTP_* fields (and RedisURL, when Redis is
89
+ // the queue's backing store) to Config and their env() loads to Load() — via
90
+ // marker comments, the same text-insertion approach as patchMainGo, since
91
+ // config.go.hbs is only rendered once (at `create`) and everything after
92
+ // that is a real file a human may have already edited.
93
+ function patchConfigForWorker(configGoPath, opts) {
94
+ if (opts.redis)
95
+ patchConfigForRedis(configGoPath);
96
+ patchConfigForSMTP(configGoPath);
97
+ }
98
+ // Sentinels here match a bare identifier, never `Name string` or
99
+ // `Name: env(...)`: gofmt aligns struct fields and map values into columns, so
100
+ // the moment one of these blocks lands the literal spacing a sentinel was
101
+ // written with no longer exists in the file. A sentinel that misses means the
102
+ // block gets inserted a second time and the project stops compiling on a
103
+ // redeclared field — which is exactly what `add auth` then `add worker` did.
104
+ //
105
+ // patchConfigForSMTP is split out because `add auth` needs these fields even
106
+ // when there is no worker: without a queue it sends mail synchronously, and it
107
+ // still has to know where to send it. Idempotent, so whichever command gets
108
+ // here first wins and the second is a no-op.
109
+ function patchConfigForSMTP(configGoPath) {
110
+ let content = fs_extra_1.default.readFileSync(configGoPath, "utf8");
111
+ const fieldsBlock = ["SMTPHost string", "SMTPPort string", "SMTPUsername string", "SMTPPassword string", "SMTPFrom string"].join("\n");
112
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_FIELDS_MARKER, fieldsBlock, "SMTPHost");
25
113
  const loadBlock = [
26
- 'RedisURL: env("REDIS_URL", "redis://localhost:6379/0"),',
27
- "",
28
114
  'SMTPHost: env("SMTP_HOST", ""),',
29
115
  'SMTPPort: env("SMTP_PORT", "587"),',
30
116
  'SMTPUsername: env("SMTP_USERNAME", ""),',
31
117
  'SMTPPassword: env("SMTP_PASSWORD", ""),',
32
118
  'SMTPFrom: env("SMTP_FROM", "no-reply@example.local"),',
33
119
  ].join("\n");
34
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, loadBlock, 'RedisURL: env("REDIS_URL"');
120
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_LOAD_MARKER, loadBlock, 'env("SMTP_HOST"');
35
121
  fs_extra_1.default.writeFileSync(configGoPath, content);
36
122
  }
37
123
  // patchMainGoForWorker wires Redis into cmd/api: opened alongside the DB, and
@@ -44,7 +130,7 @@ function patchMainGoForWorker(mainGoPath, goModule) {
44
130
  let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
45
131
  const cacheImport = `"${goModule}/internal/platform/cache"`;
46
132
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, cacheImport, cacheImport);
47
- const initBlock = ["rdb, err := cache.Open(cfg)", "if err != nil {", '\tlogger.Error("open redis", "error", err)', "\tos.Exit(1)", "}"].join("\n");
133
+ const initBlock = ["rdb, err := cache.Open(cfg)", "if err != nil {", '\treturn fmt.Errorf("open redis: %w", err)', "}"].join("\n");
48
134
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, PLATFORM_INIT_MARKER, initBlock, "rdb, err := cache.Open(cfg)");
49
135
  const readyzBlock = [
50
136
  "if err := rdb.Ping(c.Request.Context()).Err(); err != nil {",
@@ -13,12 +13,17 @@ exports.patchUserServiceTestForRbac = patchUserServiceTestForRbac;
13
13
  exports.patchUserDTOForRbac = patchUserDTOForRbac;
14
14
  exports.patchUserHandlerForRbac = patchUserHandlerForRbac;
15
15
  exports.patchUserErrorsForRbac = patchUserErrorsForRbac;
16
+ exports.userSvcLineFor = userSvcLineFor;
17
+ exports.assertRbacPatchable = assertRbacPatchable;
16
18
  exports.patchMainGoForRbac = patchMainGoForRbac;
17
19
  exports.patchCmdSeedForRbac = patchCmdSeedForRbac;
18
20
  const fs_extra_1 = __importDefault(require("fs-extra"));
21
+ const auth_patcher_1 = require("./auth-patcher");
19
22
  const marker_patch_1 = require("./marker-patch");
20
23
  const IMPORT_MARKER = "// go-scaffold:imports";
24
+ const SCHEMA_MARKER = "// go-scaffold:schemas";
21
25
  const MODEL_MARKER = "// go-scaffold:models";
26
+ const ROUTE_MARKER = "// go-scaffold:routes";
22
27
  const CONFIG_FIELDS_MARKER = "// go-scaffold:config-fields";
23
28
  const CONFIG_LOAD_MARKER = "// go-scaffold:config-load";
24
29
  // patchAuthDocsForRbac adds `role` to the hand-written MeResponse schema in
@@ -233,26 +238,71 @@ function patchUserErrorsForRbac(errorsGoPath) {
233
238
  // isn't enough — REPLACES the `add auth` PR's user.NewHandler(...) call with
234
239
  // a version that also builds roleSvc/authz and passes them through, plus
235
240
  // registers role's own routes right after it.
236
- function patchMainGoForRbac(mainGoPath, goModule) {
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.
247
+ function userSvcLineFor(goModule, store, worker) {
248
+ const { tokenStore, mailer } = (0, auth_patcher_1.authWiringLines)({ goModule, queueBackend: "river", store, worker });
249
+ return `userSvc := user.NewService(user.NewRepository(db), ${tokenStore}, ${mailer}, cfg)`;
250
+ }
251
+ function assertRbacPatchable(mainGoPath, goModule, store, worker) {
252
+ const expected = userSvcLineFor(goModule, store, worker);
253
+ if (fs_extra_1.default.readFileSync(mainGoPath, "utf8").includes(expected))
254
+ 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" +
256
+ `Expected to find:\n ${expected}\n\n` +
257
+ "It was probably hand-edited. Restore that line and re-run — nothing has been changed yet.");
258
+ }
259
+ function patchMainGoForRbac(mainGoPath, goModule, store, worker) {
237
260
  let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
238
261
  const importLine = `"${goModule}/internal/app/role"`;
239
262
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
240
263
  const modelImportLine = `rolemodel "${goModule}/internal/app/role/model"`;
241
264
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
265
+ const schemaBlock = [
266
+ 'if err := db.Exec("CREATE SCHEMA IF NOT EXISTS role_svc").Error; err != nil {',
267
+ '\treturn fmt.Errorf("create schema role_svc: %w", err)',
268
+ "}",
269
+ ].join("\n");
270
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS role_svc");
242
271
  const migrateLines = ["&rolemodel.Role{},", "&rolemodel.Permission{},", "&rolemodel.RolePermission{},"];
243
272
  for (const line of migrateLines) {
244
273
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
245
274
  }
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);
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.`);
255
293
  }
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);
256
306
  fs_extra_1.default.writeFileSync(mainGoPath, content);
257
307
  }
258
308
  // patchCmdSeedForRbac makes the seeded admin actually an admin: wires a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nakedev/go-scaffold",
3
- "version": "0.1.4",
3
+ "version": "0.3.1",
4
4
  "description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,9 +12,7 @@
12
12
  "files": [
13
13
  "dist",
14
14
  "templates",
15
- "bin",
16
- "scripts",
17
- "tests"
15
+ "bin"
18
16
  ],
19
17
  "type": "commonjs",
20
18
  "packageManager": "pnpm@11.0.7",
@@ -26,7 +24,7 @@
26
24
  "test:integration": "node --test tests/integration/*.test.mjs",
27
25
  "test:smoke": "node scripts/smoke-test.mjs",
28
26
  "verify": "pnpm run build && pnpm run test",
29
- "prepack": "pnpm run build",
27
+ "prepack": "rm -rf dist && pnpm run build",
30
28
  "prepublishOnly": "pnpm run verify"
31
29
  },
32
30
  "keywords": [
@@ -21,6 +21,13 @@ func errEmailTaken() *apperror.AppError {
21
21
  // errInvalidCredentials is returned for both "no such user" and "wrong
22
22
  // password" — a single generic message so a login attempt can't be used to
23
23
  // enumerate which emails have accounts.
24
+ // errTooManyAttempts is the account-level lockout, distinct from the per-IP
25
+ // RATE_LIMITED the middleware returns: this one follows the account wherever
26
+ // the attempts come from.
27
+ func errTooManyAttempts() *apperror.AppError {
28
+ return apperror.New(http.StatusTooManyRequests, "AUTH_TOO_MANY_ATTEMPTS", "too many failed attempts — try again later")
29
+ }
30
+
24
31
  func errInvalidCredentials() *apperror.AppError {
25
32
  return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_CREDENTIALS", "invalid email or password")
26
33
  }
@@ -2,6 +2,7 @@ package user
2
2
 
3
3
  import (
4
4
  "net/http"
5
+ "strings"
5
6
  "time"
6
7
 
7
8
  "{{goModule}}/internal/shared/httpx"
@@ -10,19 +11,26 @@ import (
10
11
 
11
12
  "github.com/gin-gonic/gin"
12
13
  "github.com/google/uuid"
13
- "github.com/redis/go-redis/v9"
14
14
  )
15
15
 
16
- const refreshCookieName = "refresh_token"
16
+ const (
17
+ refreshCookieName = "refresh_token"
18
+ // oauthStateCookieName holds the nonce that binds a Google login to the
19
+ // browser that started it — see Service.GoogleLoginURL.
20
+ oauthStateCookieName = "oauth_state"
21
+ )
17
22
 
18
23
  // go-scaffold:user-handler-consts
19
24
 
20
25
  type Handler struct {
21
- svc *Service
22
- jwtSecret string
23
- refreshTTL time.Duration
24
- cookieSecure bool
25
- rdb *redis.Client
26
+ svc *Service
27
+ jwtSecret string
28
+ refreshTTL time.Duration
29
+ cookieSecure bool
30
+ cookieSameSite string
31
+ // limiter, not a *redis.Client: which backing store counts the requests is
32
+ // decided by `add auth --store`, and this file must not care.
33
+ limiter middleware.Limiter
26
34
  // go-scaffold:user-handler-fields
27
35
  }
28
36
 
@@ -31,15 +39,17 @@ func NewHandler(
31
39
  jwtSecret string,
32
40
  refreshTTL time.Duration,
33
41
  cookieSecure bool,
34
- rdb *redis.Client,
42
+ cookieSameSite string,
43
+ limiter middleware.Limiter,
35
44
  // go-scaffold:user-handler-params
36
45
  ) *Handler {
37
46
  return &Handler{
38
- svc: svc,
39
- jwtSecret: jwtSecret,
40
- refreshTTL: refreshTTL,
41
- cookieSecure: cookieSecure,
42
- rdb: rdb,
47
+ svc: svc,
48
+ jwtSecret: jwtSecret,
49
+ refreshTTL: refreshTTL,
50
+ cookieSecure: cookieSecure,
51
+ cookieSameSite: cookieSameSite,
52
+ limiter: limiter,
43
53
  // go-scaffold:user-handler-init
44
54
  }
45
55
  }
@@ -52,12 +62,12 @@ func (h *Handler) Register(rg gin.IRouter) {
52
62
  // doesn't spend another's budget. refresh/logout/google aren't limited:
53
63
  // refresh/logout are gated by possessing a valid cookie already, and the
54
64
  // Google flow's abuse surface lives on Google's side, not ours.
55
- loginLimit := middleware.RateLimit(h.rdb, "login", 10, time.Minute)
56
- registerLimit := middleware.RateLimit(h.rdb, "register", 5, time.Minute)
57
- forgotPasswordLimit := middleware.RateLimit(h.rdb, "forgot-password", 5, time.Minute)
58
- resetPasswordLimit := middleware.RateLimit(h.rdb, "reset-password", 10, time.Minute)
59
- verifyEmailLimit := middleware.RateLimit(h.rdb, "verify-email", 10, time.Minute)
60
- resendVerificationLimit := middleware.RateLimit(h.rdb, "resend-verification", 5, time.Minute)
65
+ loginLimit := middleware.RateLimit(h.limiter, "login", 10, time.Minute)
66
+ registerLimit := middleware.RateLimit(h.limiter, "register", 5, time.Minute)
67
+ forgotPasswordLimit := middleware.RateLimit(h.limiter, "forgot-password", 5, time.Minute)
68
+ resetPasswordLimit := middleware.RateLimit(h.limiter, "reset-password", 10, time.Minute)
69
+ verifyEmailLimit := middleware.RateLimit(h.limiter, "verify-email", 10, time.Minute)
70
+ resendVerificationLimit := middleware.RateLimit(h.limiter, "resend-verification", 5, time.Minute)
61
71
 
62
72
  authGroup := rg.Group("/auth")
63
73
  authGroup.POST("/register", registerLimit, h.register)
@@ -194,16 +204,26 @@ func (h *Handler) logoutAll(c *gin.Context) {
194
204
  }
195
205
 
196
206
  func (h *Handler) googleLogin(c *gin.Context) {
197
- url, err := h.svc.GoogleLoginURL()
207
+ url, nonce, err := h.svc.GoogleLoginURL()
198
208
  if err != nil {
199
209
  c.Error(err)
200
210
  return
201
211
  }
212
+ // Lax, not Strict, and deliberately not h.cookieSameSite: the callback
213
+ // arrives as a top-level navigation from Google, i.e. cross-site, and a
214
+ // Strict cookie is not sent on one — the flow would fail every time.
215
+ c.SetSameSite(http.SameSiteLaxMode)
216
+ c.SetCookie(oauthStateCookieName, nonce, int(oauthStateTTL.Seconds()), "/", "", h.cookieSecure, true)
202
217
  c.Redirect(http.StatusFound, url)
203
218
  }
204
219
 
205
220
  func (h *Handler) googleCallback(c *gin.Context) {
206
- auth, err := h.svc.GoogleCallback(c.Request.Context(), c.Query("code"), c.Query("state"))
221
+ nonce, _ := c.Cookie(oauthStateCookieName)
222
+ // One shot, whatever happens next.
223
+ c.SetSameSite(http.SameSiteLaxMode)
224
+ c.SetCookie(oauthStateCookieName, "", -1, "/", "", h.cookieSecure, true)
225
+
226
+ auth, err := h.svc.GoogleCallback(c.Request.Context(), c.Query("code"), c.Query("state"), nonce)
207
227
  if err != nil {
208
228
  c.Error(err)
209
229
  return
@@ -225,11 +245,32 @@ func (h *Handler) me(c *gin.Context) {
225
245
  // go-scaffold:user-handler-funcs
226
246
 
227
247
  func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
228
- c.SetSameSite(http.SameSiteStrictMode)
248
+ c.SetSameSite(sameSiteFrom(h.cookieSameSite))
229
249
  c.SetCookie(refreshCookieName, token, int(h.refreshTTL.Seconds()), "/", "", h.cookieSecure, true)
230
250
  }
231
251
 
232
252
  func (h *Handler) clearRefreshCookie(c *gin.Context) {
233
- c.SetSameSite(http.SameSiteStrictMode)
253
+ c.SetSameSite(sameSiteFrom(h.cookieSameSite))
234
254
  c.SetCookie(refreshCookieName, "", -1, "/", "", h.cookieSecure, true)
235
255
  }
256
+
257
+ // sameSiteFrom maps COOKIE_SAMESITE onto the http constant, defaulting to the
258
+ // strictest option for anything it doesn't recognise.
259
+ //
260
+ // "strict" is right while the frontend and this API are the same site
261
+ // (localhost:3000 -> localhost:8080 is, and so is app.example.com ->
262
+ // api.example.com). A frontend on a genuinely different site — the usual
263
+ // vercel.app-plus-own-API-domain split — needs "none", because the browser
264
+ // will not attach a Strict or Lax cookie to the fetch that calls
265
+ // /auth/refresh: sessions then die at every access-token expiry with no
266
+ // error anywhere to explain it. "none" requires COOKIE_SECURE=true.
267
+ func sameSiteFrom(mode string) http.SameSite {
268
+ switch strings.ToLower(mode) {
269
+ case "none":
270
+ return http.SameSiteNoneMode
271
+ case "lax":
272
+ return http.SameSiteLaxMode
273
+ default:
274
+ return http.SameSiteStrictMode
275
+ }
276
+ }
@@ -3,6 +3,7 @@ package user
3
3
  import (
4
4
  "crypto/rand"
5
5
  "crypto/sha256"
6
+ "crypto/subtle"
6
7
  "encoding/hex"
7
8
  "time"
8
9
 
@@ -22,6 +23,8 @@ const (
22
23
  // presented where the other is expected.
23
24
  type accessClaims struct {
24
25
  Typ string `json:"typ"`
26
+ // Nonce is only set on the OAuth state token — see issueOAuthState.
27
+ Nonce string `json:"nonce,omitempty"`
25
28
  // go-scaffold:jwt-claims
26
29
  jwt.RegisteredClaims
27
30
  }
@@ -42,21 +45,37 @@ func (s *Service) issueAccessToken(
42
45
  return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
43
46
  }
44
47
 
45
- // issueOAuthState / verifyOAuthState round-trip a short-lived, stateless CSRF
46
- // token through Google's own redirect no server-side row needed, the JWT's
47
- // signature + short TTL is the whole protection.
48
- func (s *Service) issueOAuthState() (string, error) {
48
+ // issueOAuthState / verifyOAuthState round-trip a short-lived CSRF token
49
+ // through Google's own redirect. The signature and TTL are not the whole
50
+ // protection: a signed-but-unbound state is one anyone can fetch from
51
+ // GET /auth/google/login and then replay in a victim's browser, which is
52
+ // exactly the login-CSRF the state parameter exists to stop. So the token
53
+ // carries a nonce that the handler also drops in a short-lived httpOnly
54
+ // cookie, and the callback only proceeds when the two agree — the state is
55
+ // then usable in one browser only, the one that started the flow.
56
+ //
57
+ // Returns the state and the nonce to put in that cookie.
58
+ func (s *Service) issueOAuthState() (string, string, error) {
59
+ nonce, err := randomToken()
60
+ if err != nil {
61
+ return "", "", err
62
+ }
49
63
  claims := accessClaims{
50
- Typ: tokenTypeOAuthState,
64
+ Typ: tokenTypeOAuthState,
65
+ Nonce: nonce,
51
66
  RegisteredClaims: jwt.RegisteredClaims{
52
67
  IssuedAt: jwt.NewNumericDate(time.Now()),
53
68
  ExpiresAt: jwt.NewNumericDate(time.Now().Add(oauthStateTTL)),
54
69
  },
55
70
  }
56
- return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
71
+ state, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
72
+ if err != nil {
73
+ return "", "", err
74
+ }
75
+ return state, nonce, nil
57
76
  }
58
77
 
59
- func (s *Service) verifyOAuthState(raw string) error {
78
+ func (s *Service) verifyOAuthState(raw, nonce string) error {
60
79
  var claims accessClaims
61
80
  _, err := jwt.ParseWithClaims(raw, &claims, func(*jwt.Token) (any, error) {
62
81
  return []byte(s.jwtSecret), nil
@@ -64,6 +83,11 @@ func (s *Service) verifyOAuthState(raw string) error {
64
83
  if err != nil || claims.Typ != tokenTypeOAuthState {
65
84
  return errInvalidToken()
66
85
  }
86
+ // A missing cookie is a mismatch, not a pass — otherwise stripping the
87
+ // cookie is all it takes to get the old, unbound behavior back.
88
+ if nonce == "" || subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(nonce)) != 1 {
89
+ return errInvalidToken()
90
+ }
67
91
  return nil
68
92
  }
69
93
 
@@ -0,0 +1,39 @@
1
+ package model
2
+
3
+ import (
4
+ "time"
5
+
6
+ "github.com/google/uuid"
7
+ )
8
+
9
+ // AuthToken is one short-lived token — the Postgres equivalent of the keys the
10
+ // Redis store would hold. Only the SHA-256 hash of the raw token is stored, so
11
+ // a database dump never yields a usable token.
12
+ //
13
+ // `Kind` mirrors the Redis key prefixes one-for-one, deliberately:
14
+ //
15
+ // refresh an active refresh token
16
+ // refresh_used a tombstone left by rotation, so replaying a rotated-out
17
+ // token is detectable as reuse rather than merely unknown
18
+ // pwreset password reset, consumed once
19
+ // emailverify email verification, consumed once
20
+ //
21
+ // Keeping the tombstone a separate row (rather than a used_at column on the
22
+ // refresh row) is what preserves logout's semantics: logout deletes the row
23
+ // outright and leaves no tombstone, so someone replaying a logged-out token
24
+ // gets a plain rejection instead of tripping reuse detection and nuking every
25
+ // session the user has.
26
+ //
27
+ // No foreign key to users on purpose: GORM's AutoMigrate (dev) wouldn't create
28
+ // one from these tags, and a constraint that exists in production but not in
29
+ // development is the exact mismatch that stops the app booting. A token whose
30
+ // user is gone simply fails the lookup that follows.
31
+ type AuthToken struct {
32
+ TokenHash string `gorm:"primaryKey;type:text"`
33
+ UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_auth_tokens_user_kind,priority:1"`
34
+ Kind string `gorm:"type:varchar(20);not null;index:idx_auth_tokens_user_kind,priority:2"`
35
+ ExpiresAt time.Time `gorm:"not null;index:idx_auth_tokens_expires_at"`
36
+ CreatedAt time.Time
37
+ }
38
+
39
+ func (AuthToken) TableName() string { return "user_svc.auth_tokens" }
@@ -26,3 +26,6 @@ type Identity struct {
26
26
  CreatedAt time.Time `json:"created_at"`
27
27
  UpdatedAt time.Time `json:"updated_at"`
28
28
  }
29
+
30
+ // TableName — see User.TableName; same schema, same reasoning.
31
+ func (Identity) TableName() string { return "user_svc.identities" }
@@ -0,0 +1,26 @@
1
+ package model
2
+
3
+ import "time"
4
+
5
+ // LoginThrottle is the failed-attempt counter that makes lockout survive a
6
+ // deploy and mean the same thing on every replica. It is not the rate limiter
7
+ // — that one counts requests per IP and is allowed to be approximate. This
8
+ // counts failures per account, and per OWASP that is the control that actually
9
+ // stops credential stuffing: an attacker with a proxy pool keeps per-IP volume
10
+ // under any threshold you set, but they cannot spread attempts against one
11
+ // account across accounts.
12
+ //
13
+ // EmailHash, not the address: the counter has to be keyed on what the caller
14
+ // typed whether or not an account exists — otherwise "did this get throttled"
15
+ // answers "does this account exist" — and hashing means this table never
16
+ // becomes a directory of who has signed up.
17
+ type LoginThrottle struct {
18
+ EmailHash string `gorm:"primaryKey;type:text"`
19
+ Failures int `gorm:"not null;default:0"`
20
+ // nil until the free attempts are spent; afterwards it moves further out
21
+ // with each failure.
22
+ LockedUntil *time.Time
23
+ UpdatedAt time.Time
24
+ }
25
+
26
+ func (LoginThrottle) TableName() string { return "user_svc.login_throttle" }
@@ -12,7 +12,10 @@ import (
12
12
  // resolving to one account.
13
13
  type User struct {
14
14
  ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
15
- Email string `json:"email" gorm:"uniqueIndex;not null"`
15
+ // index named explicitly, and named the same in create_users.up.sql: an
16
+ // anonymous `uniqueIndex` makes GORM invent one, and AutoMigrate then
17
+ // tries to DROP the differently-named constraint the migration created.
18
+ Email string `json:"email" gorm:"uniqueIndex:idx_users_email;not null"`
16
19
  Name string `json:"name"`
17
20
  AvatarURL string `json:"avatar_url"`
18
21
  EmailVerified bool `json:"email_verified"`
@@ -20,3 +23,8 @@ type User struct {
20
23
  CreatedAt time.Time `json:"created_at"`
21
24
  UpdatedAt time.Time `json:"updated_at"`
22
25
  }
26
+
27
+ // TableName pins this to user_svc rather than GORM's default inflection
28
+ // ("users", schema-less) — every domain gets its own schema, see
29
+ // docs/architect/patterns.md.
30
+ func (User) TableName() string { return "user_svc.users" }