@nakedev/go-scaffold 0.5.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +16 -2
  2. package/dist/commands/auth.js +6 -1
  3. package/dist/commands/check.js +5 -1
  4. package/dist/commands/method.js +27 -1
  5. package/dist/index.js +20 -6
  6. package/dist/prompts/auth-wizard.js +33 -1
  7. package/dist/templates/create-manifest.js +8 -0
  8. package/dist/templates/rbac-manifest.js +1 -0
  9. package/dist/utils/hexagonal-method-patcher.js +79 -20
  10. package/package.json +1 -1
  11. package/templates/add/auth/docs/login.yaml.hbs +9 -1
  12. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +12 -2
  13. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +1 -0
  14. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +97 -13
  15. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +169 -5
  16. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +44 -2
  17. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +2 -1
  18. package/templates/add/auth/internal/app/user/application/service.go.hbs +1 -1
  19. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +71 -12
  20. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +5 -4
  21. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +4 -0
  22. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +21 -3
  23. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  24. package/templates/add/rbac/docs/roles.yaml.hbs +2 -1
  25. package/templates/add/rbac/docs/users.yaml.hbs +2 -1
  26. package/templates/add/rbac/internal/app/role/adapters/inbound/http/dto.go.hbs +52 -0
  27. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +18 -13
  28. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +21 -4
  29. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +19 -11
  30. package/templates/add/rbac/internal/app/role/application/service.go.hbs +6 -6
  31. package/templates/add/rbac/internal/app/role/application/service_test.go.hbs +19 -1
  32. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +13 -1
  33. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +1 -1
  34. package/templates/create/base/AGENTS.md.hbs +9 -1
  35. package/templates/create/base/README.md.hbs +2 -1
  36. package/templates/create/base/internal/shared/dbq/dbq.go.hbs +47 -0
  37. package/templates/create/base/internal/shared/dbq/dbq_test.go.hbs +66 -0
  38. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +19 -2
  39. package/templates/create/features/docs/architecture.md.hbs +9 -6
  40. package/templates/create/features/docs/common/parameters.yaml.hbs +5 -0
  41. package/templates/create/features/docs/common/schemas.yaml.hbs +1 -0
  42. package/templates/create/features/docs/patterns.md.hbs +60 -0
  43. package/templates/generate/module/docs/collection.yaml.hbs +2 -1
  44. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +8 -3
  45. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +27 -4
  46. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +3 -3
  47. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +3 -3
  48. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +3 -3
  49. package/templates/generate/module/hexagonal/application/service_test.go.hbs +4 -3
  50. package/templates/generate/module/hexagonal/ports/repository.go.hbs +18 -2
package/README.md CHANGED
@@ -482,11 +482,24 @@ Auth adds:
482
482
  `password_credentials`, and `external_identities`; a Google-only user can
483
483
  add a password through `POST /users/me/identities/local` without creating a
484
484
  second account
485
- - failed-login lockout and user-session management
485
+ - failed-login lockout in either of two shapes (`--lockout`), both ignoring a
486
+ repeated wrong password so a stale saved credential cannot lock the owner
487
+ out — and user-session management
486
488
  - MFA endpoints and configuration hooks
487
489
  - internal/app/user, auth middleware, cmd/seed, migrations, and OpenAPI
488
490
  documents when OpenAPI is enabled
489
491
 
492
+ The --lockout choice controls what repeated failed logins cost:
493
+
494
+ | --lockout | Policy | A patient attacker gets |
495
+ |---|---|---|
496
+ | progressive (default) | 3 free attempts, then the wait doubles from 2s to a 15 minute ceiling | ~4 guesses/hour |
497
+ | fixed | 10 attempts, then a 5 minute lock; the count clears after 15 quiet minutes | ~12 guesses/hour |
498
+
499
+ Both are per-account, temporary, and need no admin to unlock. `fixed` is the
500
+ shape AD/Entra administrators expect and is kinder to someone who simply
501
+ forgot their password; `progressive` starts costing time sooner.
502
+
490
503
  The --store choice controls refresh-token storage and rate-limit counters:
491
504
 
492
505
  | --store | Refresh/recovery token storage | Extra service |
@@ -584,10 +597,11 @@ my-api/
584
597
  │ │ ├── config/ # environment configuration
585
598
  │ │ ├── apperror/ # consistent application errors
586
599
  │ │ ├── dberr/ # database error classification
600
+ │ │ ├── dbq/ # escaped contains-search for list filters
587
601
  │ │ ├── httpx/ # HTTP parsing and binding helpers
588
602
  │ │ ├── id/ # UUID generation
589
603
  │ │ ├── middleware/ # request ID, logging, errors, CORS
590
- │ │ ├── pagination/ # pagination parsing and responses
604
+ │ │ ├── pagination/ # pagination and ?q= parsing, responses
591
605
  │ │ └── tx/ # transaction context helpers
592
606
  │ └── app/ # empty until generate module is used
593
607
  ├── migrations/ # embedded, versioned SQL migrations
@@ -56,9 +56,10 @@ const AUTH_OPENAPI_PATHS = [
56
56
  // (no roles/permissions) — that's a separate opt-in on top of this, since
57
57
  // most projects need "is this caller logged in" long before they need "can
58
58
  // this caller do X".
59
- async function addAuth(store = "postgres", projectDir = process.cwd(), browserTopology = auth_wizard_1.DEFAULT_BROWSER_TOPOLOGY) {
59
+ async function addAuth(store = "postgres", projectDir = process.cwd(), browserTopology = auth_wizard_1.DEFAULT_BROWSER_TOPOLOGY, lockout = auth_wizard_1.DEFAULT_LOCKOUT_POLICY) {
60
60
  const config = (0, config_1.readConfig)(projectDir);
61
61
  const browser = (0, auth_wizard_1.validateBrowserTopology)(browserTopology);
62
+ const lockoutPolicy = (0, auth_wizard_1.validateLockoutPolicy)(lockout);
62
63
  // No longer a prerequisite. Without a worker the verification and reset mail
63
64
  // goes out inline instead of through a queue — a real trade (those two
64
65
  // endpoints then block on SMTP), but not one worth forcing a second binary
@@ -83,6 +84,10 @@ async function addAuth(store = "postgres", projectDir = process.cwd(), browserTo
83
84
  goModule: config.goModule,
84
85
  redis: store === "redis",
85
86
  worker,
87
+ // one flag rather than the policy name: the templates only ever ask
88
+ // "which shape", and a second policy name in Handlebars would need an
89
+ // equality helper the renderer does not have
90
+ fixedLockout: lockoutPolicy === "fixed",
86
91
  });
87
92
  const migrationsDir = path_1.default.join(projectDir, "migrations");
88
93
  fs_extra_1.default.ensureDirSync(migrationsDir);
@@ -218,7 +218,11 @@ function checkModule(projectDir, config, name, module) {
218
218
  const rootGoFiles = fs_extra_1.default.existsSync(moduleDir)
219
219
  ? fs_extra_1.default
220
220
  .readdirSync(moduleDir, { withFileTypes: true })
221
- .filter((entry) => entry.isFile() && entry.name.endsWith(".go"))
221
+ // `_test.go` excluded: the rule is about where implementation lives,
222
+ // and `package <mod>_test` beside composition.go is the only place Go
223
+ // lets you test the composition root end to end. It is imported by
224
+ // nothing and adds no dependency edge, so it is not a layout breach.
225
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".go") && !entry.name.endsWith("_test.go"))
222
226
  .map((entry) => path_1.default.join(moduleDir, entry.name))
223
227
  : [];
224
228
  for (const file of rootGoFiles) {
@@ -70,6 +70,15 @@ async function generateHexagonalMethod(config, naming, methodNameArg, opts, proj
70
70
  }
71
71
  const cqrs = moduleConfig.applicationStyle === "cqrs";
72
72
  const authModule = naming.pkg === "user" && fs_extra_1.default.existsSync(path_1.default.join(moduleDir, "application", "contracts.go"));
73
+ // `add rbac`'s role module, not a module someone generated and named "role":
74
+ // the generated one has no application/errors.go, so it stays extendable.
75
+ const rbacModule = naming.pkg === "role" && fs_extra_1.default.existsSync(path_1.default.join(moduleDir, "application", "errors.go"));
76
+ if (rbacModule) {
77
+ throw new Error("internal/app/role comes from `add rbac`, which builds its HTTP response from a role *and its permissions* " +
78
+ "rather than from the entity alone — `generate method` has no shape to write against there. Add the endpoint " +
79
+ "by hand: a route in internal/app/role/adapters/inbound/http/handler.go, a method on the application service, " +
80
+ "and its OpenAPI entry.");
81
+ }
73
82
  const paths = {
74
83
  dtoPath: path_1.default.join(moduleDir, "application", "dto.go"),
75
84
  requestDTOPath: path_1.default.join(moduleDir, "adapters", "inbound", "http", "dto.go"),
@@ -80,6 +89,17 @@ async function generateHexagonalMethod(config, naming, methodNameArg, opts, proj
80
89
  queryPath: cqrs ? path_1.default.join(moduleDir, "application", "queries.go") : undefined,
81
90
  handlerPath: path_1.default.join(moduleDir, "adapters", "inbound", "http", "handler.go"),
82
91
  serviceTestPath: path_1.default.join(moduleDir, "application", moduleConfig.applicationStyle === "cqrs" ? "cqrs_test.go" : "service_test.go"),
92
+ // What `add auth` and `add rbac` call the things the patcher writes
93
+ // against. They are finished features rather than starting points, so
94
+ // they name their persistence model, their mappers and their response
95
+ // type after themselves, not after the vocabulary `generate module`
96
+ // emits. Anything left out here is emitted with the generated module's
97
+ // name and compiles to `undefined: <that name>`.
98
+ //
99
+ // The route group, the HTTP error mapper and the application package
100
+ // alias are *not* here: those are read out of the handler being patched
101
+ // (hexagonal-method-patcher.ts), which is why this table is three
102
+ // entries shorter than the number of names that differ.
83
103
  ...(authModule
84
104
  ? {
85
105
  repositoryModelType: "User",
@@ -88,9 +108,15 @@ async function generateHexagonalMethod(config, naming, methodNameArg, opts, proj
88
108
  repositoryToDomainCallReturnsError: true,
89
109
  repositoryErrorMapper: "persistenceError",
90
110
  repositoryStubReceiver: "f *fakeRepo",
91
- handlerErrorMapper: "toHTTPError",
92
111
  }
93
112
  : {}),
113
+ // rbac is deliberately absent. Renaming was never enough for it: its
114
+ // `toDomainRole` returns a value where every other module returns a
115
+ // pointer, and `ToRoleResponse` takes a RoleListItem — a role together
116
+ // with its permission codes — where the others take the entity. No name
117
+ // substitution reconciles a different shape, so `generate method` refuses
118
+ // the module instead of writing code that resolves and then will not
119
+ // type-check.
94
120
  };
95
121
  const required = [
96
122
  paths.dtoPath,
package/dist/index.js CHANGED
@@ -382,7 +382,7 @@ async function runAddWizard() {
382
382
  // all), so the menu has to ask it the same way the worker menu asks for
383
383
  // its queue backend — a choice only reachable by knowing the flag name
384
384
  // isn't a choice for anyone driving this from the menu.
385
- await runAddAuth(await (0, auth_wizard_1.promptAuthStore)(), await (0, auth_wizard_1.promptBrowserTopology)(), {});
385
+ await runAddAuth(await (0, auth_wizard_1.promptAuthStore)(), await (0, auth_wizard_1.promptBrowserTopology)(), await (0, auth_wizard_1.promptLockoutPolicy)(), {});
386
386
  }
387
387
  else if (target === "rbac") {
388
388
  await runAddRbac({});
@@ -400,7 +400,7 @@ async function runAddWorker(backend, opts) {
400
400
  ], opts);
401
401
  await (0, worker_1.addWorker)(backend);
402
402
  }
403
- async function runAddAuth(store, browserTopology, opts) {
403
+ async function runAddAuth(store, browserTopology, lockout, opts) {
404
404
  const config = (0, config_2.readConfig)(process.cwd());
405
405
  await confirmAdd([
406
406
  "add internal/app/user/, internal/shared/middleware/auth.go, and cmd/seed",
@@ -408,11 +408,14 @@ async function runAddAuth(store, browserTopology, opts) {
408
408
  store === "postgres"
409
409
  ? "refresh + recovery tokens: Postgres (user_svc.auth_tokens), rate-limit counters in-process — no extra service"
410
410
  : picocolors_1.default.yellow("refresh tokens + rate-limit counters: Redis; recovery tokens: Postgres — requires Redis"),
411
+ lockout === "progressive"
412
+ ? "failed logins: 3 free attempts, then a doubling wait up to 15 minutes"
413
+ : "failed logins: locked for 5 minutes after 10 attempts, count cleared by 15 quiet minutes",
411
414
  config.features.worker
412
415
  ? "verification/reset mail: queued through the worker already installed"
413
416
  : picocolors_1.default.yellow("verification/reset mail: sent inline over SMTP (no worker yet) — /auth/register and /auth/forgot-password block until it's sent"),
414
417
  ], opts);
415
- await (0, auth_1.addAuth)(store, process.cwd(), browserTopology);
418
+ await (0, auth_1.addAuth)(store, process.cwd(), browserTopology, lockout);
416
419
  }
417
420
  // Explicit browser flags are intentionally resolved before the confirmation
418
421
  // summary. `--defaults` is the stable non-TTY escape hatch; `--yes` with no
@@ -425,6 +428,15 @@ async function resolveBrowserTopology(opts) {
425
428
  return auth_wizard_1.DEFAULT_BROWSER_TOPOLOGY;
426
429
  return (0, auth_wizard_1.promptBrowserTopology)();
427
430
  }
431
+ // Same shape again: the lockout shape is a real fork in the generated code, so
432
+ // it is worth asking about, but never worth blocking a scripted run over.
433
+ async function resolveLockoutPolicy(opts) {
434
+ if (opts.lockout !== undefined)
435
+ return (0, auth_wizard_1.validateLockoutPolicy)(opts.lockout);
436
+ if (opts.defaults || opts.yes)
437
+ return auth_wizard_1.DEFAULT_LOCKOUT_POLICY;
438
+ return (0, auth_wizard_1.promptLockoutPolicy)();
439
+ }
428
440
  async function runAddRbac(opts) {
429
441
  const config = (0, config_2.readConfig)(process.cwd());
430
442
  if (!config.features.auth) {
@@ -497,8 +509,9 @@ add
497
509
  .description("add email/password auth: JWT access tokens, refresh rotation, device-session listing/revocation, register/login/refresh/logout/me (no prerequisites — without `add worker` the verification/reset mail is sent inline)")
498
510
  .option("--store <store>", 'where tokens and rate-limit counters live: "postgres" (default, no extra service) or "redis" (exact across replicas)')
499
511
  .option("--browser-topology <topology>", "browser deployment topology for cookie/CORS policy: same-origin, same-site (different origin), or cross-site (requires HTTPS deployment)")
500
- .option("--defaults", "skip store, browser-topology, and confirmation prompts; use Postgres plus local same-site defaults")
501
- .option("-y, --yes", "skip confirmation; omitted store/topology use local Postgres and same-site defaults")
512
+ .option("--lockout <policy>", 'how repeated failed logins are refused: "progressive" (default, 3 free then a doubling wait to 15 minutes) or "fixed" (10 attempts, 5 minute lock, count cleared after 15 quiet minutes)')
513
+ .option("--defaults", "skip store, browser-topology, lockout, and confirmation prompts; use Postgres plus local same-site defaults")
514
+ .option("-y, --yes", "skip confirmation; omitted store/topology/lockout use local Postgres, same-site, and progressive defaults")
502
515
  .action(async (opts) => {
503
516
  try {
504
517
  // Same shape as `add worker`: an explicit flag wins, --defaults takes
@@ -519,7 +532,8 @@ add
519
532
  store = await (0, auth_wizard_1.promptAuthStore)();
520
533
  }
521
534
  const browserTopology = await resolveBrowserTopology(opts);
522
- await runAddAuth(store, browserTopology, { yes: opts.yes || opts.defaults });
535
+ const lockout = await resolveLockoutPolicy(opts);
536
+ await runAddAuth(store, browserTopology, lockout, { yes: opts.yes || opts.defaults });
523
537
  }
524
538
  catch (err) {
525
539
  fail(err);
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_BROWSER_TOPOLOGY = void 0;
3
+ exports.DEFAULT_LOCKOUT_POLICY = exports.DEFAULT_BROWSER_TOPOLOGY = void 0;
4
4
  exports.promptAuthStore = promptAuthStore;
5
5
  exports.validateBrowserTopology = validateBrowserTopology;
6
6
  exports.promptBrowserTopology = promptBrowserTopology;
7
+ exports.validateLockoutPolicy = validateLockoutPolicy;
8
+ exports.promptLockoutPolicy = promptLockoutPolicy;
7
9
  const interactive_1 = require("./interactive");
8
10
  // The one decision `add auth` cannot make for you: where refresh tokens and
9
11
  // rate-limit counters live. Recovery tokens always use the durable Postgres
@@ -61,3 +63,33 @@ async function promptBrowserTopology() {
61
63
  choices: TOPOLOGIES,
62
64
  });
63
65
  }
66
+ exports.DEFAULT_LOCKOUT_POLICY = "progressive";
67
+ // The numbers in these descriptions are what the generated policy allows a
68
+ // patient attacker per account, once the free attempts are gone. Both refuse
69
+ // on their own and heal on their own; neither needs an admin to unlock.
70
+ const LOCKOUT_POLICIES = [
71
+ {
72
+ name: "Progressive delay",
73
+ value: "progressive",
74
+ description: "3 free attempts, then the wait doubles from 2s to a 15 minute ceiling — about 4 guesses an hour, but someone who forgot their password starts waiting early",
75
+ },
76
+ {
77
+ name: "Fixed lockout with a memory window",
78
+ value: "fixed",
79
+ description: "10 attempts, then locked for 5 minutes; the count clears after 15 quiet minutes — about 12 guesses an hour, and the shape AD/Entra admins already expect",
80
+ },
81
+ ];
82
+ function validateLockoutPolicy(raw) {
83
+ const value = raw.trim().toLowerCase();
84
+ if (!LOCKOUT_POLICIES.some((policy) => policy.value === value)) {
85
+ throw new Error(`Lockout policy must be one of: ${LOCKOUT_POLICIES.map((policy) => policy.value).join(", ")} (got "${raw}")`);
86
+ }
87
+ return value;
88
+ }
89
+ async function promptLockoutPolicy() {
90
+ return (0, interactive_1.select)({
91
+ message: "How should repeated failed logins be refused?",
92
+ default: exports.DEFAULT_LOCKOUT_POLICY,
93
+ choices: LOCKOUT_POLICIES,
94
+ });
95
+ }
@@ -53,6 +53,14 @@ exports.CREATE_MANIFEST = [
53
53
  template: "create/base/internal/shared/pagination/pagination.go.hbs",
54
54
  output: "internal/shared/pagination/pagination.go",
55
55
  },
56
+ {
57
+ template: "create/base/internal/shared/dbq/dbq.go.hbs",
58
+ output: "internal/shared/dbq/dbq.go",
59
+ },
60
+ {
61
+ template: "create/base/internal/shared/dbq/dbq_test.go.hbs",
62
+ output: "internal/shared/dbq/dbq_test.go",
63
+ },
56
64
  {
57
65
  template: "create/base/internal/shared/tx/tx.go.hbs",
58
66
  output: "internal/shared/tx/tx.go",
@@ -14,6 +14,7 @@ exports.RBAC_FILES = [
14
14
  { template: "add/rbac/internal/app/role/application/errors.go.hbs", output: "internal/app/role/application/errors.go" },
15
15
  { template: "add/rbac/internal/app/role/application/service.go.hbs", output: "internal/app/role/application/service.go" },
16
16
  { template: "add/rbac/internal/app/role/application/service_test.go.hbs", output: "internal/app/role/application/service_test.go" },
17
+ { template: "add/rbac/internal/app/role/adapters/inbound/http/dto.go.hbs", output: "internal/app/role/adapters/inbound/http/dto.go" },
17
18
  { template: "add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs", output: "internal/app/role/adapters/inbound/http/handler.go" },
18
19
  { template: "add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs", output: "internal/app/role/adapters/inbound/http/handler_test.go" },
19
20
  { template: "add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs", output: "internal/app/role/adapters/outbound/postgres/model.go" },
@@ -70,7 +70,7 @@ function applicationInterfaceMarker(paths, type) {
70
70
  }
71
71
  function methodSignature(naming, method, opts) {
72
72
  if (opts.type === "get" && opts.getMode === "all") {
73
- return `${method.pascalName}(context.Context, int, int) ([]domain.${naming.pascalName}, error)`;
73
+ return `${method.pascalName}(context.Context, ports.ListFilter) ([]domain.${naming.pascalName}, int64, error)`;
74
74
  }
75
75
  if (opts.type === "get") {
76
76
  return `${method.pascalName}(context.Context, string) (*domain.${naming.pascalName}, error)`;
@@ -84,8 +84,11 @@ function applicationMethod(naming, method, opts, receiver) {
84
84
  const target = receiver.startsWith("s ") ? "s" : "h";
85
85
  if (opts.type === "get" && opts.getMode === "all") {
86
86
  return [
87
- `func (${receiver}) ${method.pascalName}(ctx context.Context, limit, offset int) ([]domain.${naming.pascalName}, error) {`,
88
- `\treturn ${target}.repo.FindAll(ctx, limit, offset)`,
87
+ `// TODO: narrow this list. It reuses FindAll, so today it answers the`,
88
+ `// same rows as the module's own list — give ports.ListFilter the fields`,
89
+ `// this endpoint filters by and read them in the repository.`,
90
+ `func (${receiver}) ${method.pascalName}(ctx context.Context, filter ports.ListFilter) ([]domain.${naming.pascalName}, int64, error) {`,
91
+ `\treturn ${target}.repo.FindAll(ctx, filter)`,
89
92
  `}`,
90
93
  "",
91
94
  ].join("\n");
@@ -119,25 +122,35 @@ function applicationMethod(naming, method, opts, receiver) {
119
122
  "",
120
123
  ].join("\n");
121
124
  }
122
- function handlerMethod(naming, method, opts, cqrs, routeReceiver, errorMapper) {
125
+ // "ports" is the module's own package, not a shared one — every other token
126
+ // in an imports list resolves under internal/shared.
127
+ function handlerImportPath(need, goModule, naming) {
128
+ if (need.startsWith("net/"))
129
+ return need;
130
+ if (need === "ports")
131
+ return `${goModule}/internal/app/${naming.pkg}/ports`;
132
+ return `${goModule}/internal/shared/${need}`;
133
+ }
134
+ function handlerMethod(naming, method, opts, cqrs, routeReceiver, errorMapper, appPkg, names) {
123
135
  const receiver = cqrs ? (opts.type === "get" ? "h.queries" : "h.commands") : "h.svc";
124
136
  if (opts.type === "get" && opts.getMode === "all") {
125
137
  return {
126
138
  route: `${routeReceiver}.GET("/${method.pathSegment}", h.${method.handlerName})`,
127
- imports: ["net/http", "pagination"],
139
+ imports: ["net/http", "pagination", "ports"],
128
140
  body: [
129
141
  `func (h *Handler) ${method.handlerName}(c *gin.Context) {`,
130
142
  `\tp := pagination.Parse(c)`,
131
- `\titems, err := ${receiver}.${method.pascalName}(c.Request.Context(), p.Limit, p.Offset)`,
143
+ `\tfilter := ports.ListFilter{Search: p.Search, Limit: p.Limit, Offset: p.Offset}`,
144
+ `\titems, total, err := ${receiver}.${method.pascalName}(c.Request.Context(), filter)`,
132
145
  `\tif err != nil {`,
133
146
  `\t\tc.Error(${errorMapper}(err))`,
134
147
  `\t\treturn`,
135
148
  `\t}`,
136
- `\tout := make([]response, len(items))`,
149
+ `\tout := make([]${names.responseType}, len(items))`,
137
150
  `\tfor i := range items {`,
138
- `\t\tout[i] = toResponse(application.ToResponse(&items[i]))`,
151
+ `\t\tout[i] = ${names.responseMapper}(${appPkg}.${names.applicationResponseMapper}(&items[i]))`,
139
152
  `\t}`,
140
- `\tc.JSON(http.StatusOK, p.Response(out))`,
153
+ `\tc.JSON(http.StatusOK, p.ResponseWithTotal(out, total))`,
141
154
  `}`,
142
155
  "",
143
156
  ].join("\n"),
@@ -157,7 +170,7 @@ function handlerMethod(naming, method, opts, cqrs, routeReceiver, errorMapper) {
157
170
  `\t\tc.Error(${errorMapper}(err))`,
158
171
  `\t\treturn`,
159
172
  `\t}`,
160
- `\tc.JSON(http.StatusOK, toResponse(application.ToResponse(m)))`,
173
+ `\tc.JSON(http.StatusOK, ${names.responseMapper}(${appPkg}.${names.applicationResponseMapper}(m)))`,
161
174
  `}`,
162
175
  "",
163
176
  ].join("\n"),
@@ -179,7 +192,7 @@ function handlerMethod(naming, method, opts, cqrs, routeReceiver, errorMapper) {
179
192
  `\t\tc.Error(${errorMapper}(err))`,
180
193
  `\t\treturn`,
181
194
  `\t}`,
182
- `\tc.JSON(http.StatusCreated, toResponse(application.ToResponse(m)))`,
195
+ `\tc.JSON(http.StatusCreated, ${names.responseMapper}(${appPkg}.${names.applicationResponseMapper}(m)))`,
183
196
  `}`,
184
197
  "",
185
198
  ].join("\n"),
@@ -208,10 +221,49 @@ function assertNotDuplicate(content, needle, what) {
208
221
  if (content.includes(needle))
209
222
  throw new Error(`${what} already exists — pick a different method name`);
210
223
  }
211
- function handlerRouteReceiver(content) {
212
- // Auth's protected routes use usersGroup; generated CRUD modules use the
213
- // local g group. Both remain the module's inbound adapter boundary.
214
- return (0, marker_patch_1.hasMarker)(content, "// go-scaffold:user-routes") ? "usersGroup" : "g";
224
+ /**
225
+ * The variable a new route hangs off, read out of the handler rather than
226
+ * guessed from it.
227
+ *
228
+ * Every module declares its own group — `g` in a generated CRUD module,
229
+ * `usersGroup` in auth, `roles` in rbac — and a hard-coded pair of names
230
+ * silently emitted `g.PATCH(...)` into modules that never declared a `g`. The
231
+ * project then failed to compile with `undefined: g`, after the CLI had
232
+ * already reported success.
233
+ *
234
+ * Matched on the mounted path, not on declaration order: a module may open
235
+ * several groups (rbac has one for roles and one for permissions), and the
236
+ * one that owns the module's own collection is the one a new endpoint belongs
237
+ * to.
238
+ */
239
+ function handlerRouteReceiver(content, naming) {
240
+ const owning = new RegExp(`(\\w+)\\s*:=\\s*\\w+\\.Group\\(\\s*"/${naming.plural}"`).exec(content);
241
+ if (owning)
242
+ return owning[1];
243
+ const anyGroup = /(\w+)\s*:=\s*\w+\.Group\(/.exec(content);
244
+ if (anyGroup)
245
+ return anyGroup[1];
246
+ throw new Error("no route group found in the handler — `generate method` adds a route to an existing group and this module declares none");
247
+ }
248
+ /**
249
+ * The function that turns an application error into an HTTP one, by its
250
+ * signature rather than by a name the caller passes in. Modules disagree:
251
+ * generated ones use `appError`/`applicationError`, auth and rbac both use
252
+ * `toHTTPError`.
253
+ */
254
+ function handlerErrorMapperName(content, fallback) {
255
+ const found = /func\s+(\w+)\(err error\) error \{/.exec(content);
256
+ return found ? found[1] : fallback;
257
+ }
258
+ /**
259
+ * How the file being patched refers to the module's application package.
260
+ * `add auth` imports it aliased as `userapp`; everything else imports it
261
+ * plainly. Emitting the wrong one compiles to `undefined: application`.
262
+ */
263
+ function applicationAlias(content, goModule, naming) {
264
+ const importPath = `${goModule}/internal/app/${naming.pkg}/application`;
265
+ const aliased = new RegExp(`(\\w+)\\s+"${importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`).exec(content);
266
+ return aliased ? aliased[1] : "application";
215
267
  }
216
268
  function hexagonalMarkersPresent(paths) {
217
269
  if (!fs_extra_1.default.existsSync(paths.dtoPath) || !fs_extra_1.default.existsSync(paths.requestDTOPath) || !fs_extra_1.default.existsSync(paths.portsPath) || !fs_extra_1.default.existsSync(paths.repositoryAdapterPath) || !fs_extra_1.default.existsSync(paths.handlerPath) || !fs_extra_1.default.existsSync(paths.serviceTestPath))
@@ -259,11 +311,15 @@ function patchHexagonalMethod(paths, naming, method, opts, goModule) {
259
311
  applicationFile = insert(applicationFile, target.marker, applicationMethod(naming, method, opts, target.receiver));
260
312
  write(files, target.path, applicationFile);
261
313
  let handler = read(files, paths.handlerPath);
262
- const handlerResult = handlerMethod(naming, method, opts, cqrs, handlerRouteReceiver(handler), paths.handlerErrorMapper ?? "appError");
314
+ const handlerResult = handlerMethod(naming, method, opts, cqrs, handlerRouteReceiver(handler, naming), handlerErrorMapperName(handler, paths.handlerErrorMapper ?? "appError"), applicationAlias(handler, goModule, naming), {
315
+ responseType: paths.handlerResponseType ?? "response",
316
+ responseMapper: paths.handlerResponseMapper ?? "toResponse",
317
+ applicationResponseMapper: paths.applicationResponseMapper ?? "ToResponse",
318
+ });
263
319
  handler = insert(handler, HANDLER_ROUTES_MARKER, handlerResult.route);
264
320
  handler = insert(handler, HANDLER_FUNCS_MARKER, handlerResult.body);
265
321
  for (const need of handlerResult.imports)
266
- handler = addImport(handler, need.startsWith("net/") ? need : `${goModule}/internal/shared/${need}`);
322
+ handler = addImport(handler, handlerImportPath(need, goModule, naming));
267
323
  write(files, paths.handlerPath, handler);
268
324
  if (opts.type === "post") {
269
325
  let dto = read(files, paths.dtoPath);
@@ -273,16 +329,19 @@ function patchHexagonalMethod(paths, naming, method, opts, goModule) {
273
329
  write(files, paths.dtoPath, dto);
274
330
  let requestDTO = read(files, paths.requestDTOPath);
275
331
  assertNotDuplicate(requestDTO, `type ${inputName} struct`, `HTTP DTO "${inputName}"`);
332
+ // Read before the import is added, so an alias the file already carries
333
+ // wins over the plain name this would otherwise introduce.
334
+ const requestAppPkg = applicationAlias(requestDTO, goModule, naming);
276
335
  requestDTO = addImport(requestDTO, `${goModule}/internal/app/${naming.pkg}/application`);
277
336
  requestDTO = insert(requestDTO, requestDTOMarker(requestDTO), [
278
337
  `type ${inputName} struct {`,
279
- `\t// TODO: mirror request fields from application.${inputName} and add JSON/binding tags`,
338
+ `\t// TODO: mirror request fields from ${requestAppPkg}.${inputName} and add JSON/binding tags`,
280
339
  `}`,
281
340
  "",
282
- `func to${inputName}(in ${inputName}) application.${inputName} {`,
341
+ `func to${inputName}(in ${inputName}) ${requestAppPkg}.${inputName} {`,
283
342
  `\t// TODO: map request fields explicitly into the application input.`,
284
343
  `\t_ = in`,
285
- `\treturn application.${inputName}{}`,
344
+ `\treturn ${requestAppPkg}.${inputName}{}`,
286
345
  `}`,
287
346
  "",
288
347
  ].join("\n"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nakedev/go-scaffold",
3
- "version": "0.5.4",
3
+ "version": "0.8.0",
4
4
  "description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,4 +26,12 @@ post:
26
26
  - { $ref: './schemas.yaml#/MFAChallengeResponse' }
27
27
  "400": { $ref: '../common/responses.yaml#/ValidationError' }
28
28
  "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
29
- "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
29
+ "429":
30
+ description: |
31
+ Two different limiters answer with this status, and the body's `code`
32
+ says which: `RATE_LIMITED` is the per-IP request rate on this route,
33
+ while `AUTH_TOO_MANY_ATTEMPTS` means this account is locked out after
34
+ repeated failures and no password will be checked until it expires.
35
+ content:
36
+ application/json:
37
+ schema: { $ref: '../common/schemas.yaml#/Error' }
@@ -7,6 +7,7 @@ import (
7
7
 
8
8
  "{{goModule}}/internal/app/user/application"
9
9
  "{{goModule}}/internal/app/user/domain"
10
+ "{{goModule}}/internal/app/user/ports"
10
11
  "{{goModule}}/internal/shared/apperror"
11
12
  "{{goModule}}/internal/shared/httpx"
12
13
  "{{goModule}}/internal/shared/middleware"
@@ -154,7 +155,10 @@ func (h *Handler) Register(rg gin.IRouter) {
154
155
 
155
156
  func (h *Handler) adminListUsers(c *gin.Context) {
156
157
  p := pagination.Parse(c)
157
- items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
158
+ // One struct all the way down, so a filter added later is a field on
159
+ // ports.ListFilter and not a new argument on the three signatures below.
160
+ filter := ports.ListFilter{Search: p.Search, Limit: p.Limit, Offset: p.Offset}
161
+ items, total, err := h.svc.List(c.Request.Context(), filter)
158
162
  if err != nil {
159
163
  c.Error(toHTTPError(err))
160
164
  return
@@ -163,7 +167,7 @@ func (h *Handler) adminListUsers(c *gin.Context) {
163
167
  for i := range items {
164
168
  out[i] = toMeResponse(&items[i])
165
169
  }
166
- c.JSON(http.StatusOK, p.Response(out))
170
+ c.JSON(http.StatusOK, p.ResponseWithTotal(out, total))
167
171
  }
168
172
  func (h *Handler) adminGetUser(c *gin.Context) {
169
173
  id, ok := httpx.ParseID(c)
@@ -226,6 +230,12 @@ func toHTTPError(err error) error {
226
230
  }
227
231
  return apperror.New(status, ruleErr.Code, ruleErr.Message)
228
232
  }
233
+ // Before the catch-all: a stub `generate method` wrote has no body yet and
234
+ // 501 says so, where NewInternal would report a server fault for a route
235
+ // nobody has written.
236
+ if errors.Is(err, domain.ErrNotImplemented) {
237
+ return apperror.New(http.StatusNotImplemented, "NOT_IMPLEMENTED", "not implemented")
238
+ }
229
239
  if errors.Is(err, domain.ErrNotFound) {
230
240
  return apperror.NewNotFound("user not found")
231
241
  }
@@ -76,6 +76,7 @@ func (AuthToken) TableName() string { return "user_svc.auth_tokens" }
76
76
  type LoginThrottle struct {
77
77
  EmailHash string `gorm:"primaryKey;type:text"`
78
78
  Failures int `gorm:"not null;default:0"`
79
+ LastAttempt *string `gorm:"type:text"`
79
80
  LockedUntil *time.Time
80
81
  UpdatedAt time.Time
81
82
  }