@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.
- package/README.md +16 -2
- package/dist/commands/auth.js +6 -1
- package/dist/commands/check.js +5 -1
- package/dist/commands/method.js +27 -1
- package/dist/index.js +20 -6
- package/dist/prompts/auth-wizard.js +33 -1
- package/dist/templates/create-manifest.js +8 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/utils/hexagonal-method-patcher.js +79 -20
- package/package.json +1 -1
- package/templates/add/auth/docs/login.yaml.hbs +9 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +12 -2
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +97 -13
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +169 -5
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +44 -2
- package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +2 -1
- package/templates/add/auth/internal/app/user/application/service.go.hbs +1 -1
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +71 -12
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +5 -4
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +4 -0
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +21 -3
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +2 -1
- package/templates/add/rbac/docs/users.yaml.hbs +2 -1
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/dto.go.hbs +52 -0
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +18 -13
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +21 -4
- package/templates/add/rbac/internal/app/role/application/dto.go.hbs +19 -11
- package/templates/add/rbac/internal/app/role/application/service.go.hbs +6 -6
- package/templates/add/rbac/internal/app/role/application/service_test.go.hbs +19 -1
- package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +13 -1
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +1 -1
- package/templates/create/base/AGENTS.md.hbs +9 -1
- package/templates/create/base/README.md.hbs +2 -1
- package/templates/create/base/internal/shared/dbq/dbq.go.hbs +47 -0
- package/templates/create/base/internal/shared/dbq/dbq_test.go.hbs +66 -0
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +19 -2
- package/templates/create/features/docs/architecture.md.hbs +9 -6
- package/templates/create/features/docs/common/parameters.yaml.hbs +5 -0
- package/templates/create/features/docs/common/schemas.yaml.hbs +1 -0
- package/templates/create/features/docs/patterns.md.hbs +60 -0
- package/templates/generate/module/docs/collection.yaml.hbs +2 -1
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +8 -3
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +27 -4
- package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +3 -3
- package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +3 -3
- package/templates/generate/module/hexagonal/application/service.crud.go.hbs +3 -3
- package/templates/generate/module/hexagonal/application/service_test.go.hbs +4 -3
- 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
|
|
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
|
|
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
|
package/dist/commands/auth.js
CHANGED
|
@@ -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);
|
package/dist/commands/check.js
CHANGED
|
@@ -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
|
-
.
|
|
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) {
|
package/dist/commands/method.js
CHANGED
|
@@ -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("--
|
|
501
|
-
.option("
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
`\
|
|
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([]
|
|
149
|
+
`\tout := make([]${names.responseType}, len(items))`,
|
|
137
150
|
`\tfor i := range items {`,
|
|
138
|
-
`\t\tout[i] =
|
|
151
|
+
`\t\tout[i] = ${names.responseMapper}(${appPkg}.${names.applicationResponseMapper}(&items[i]))`,
|
|
139
152
|
`\t}`,
|
|
140
|
-
`\tc.JSON(http.StatusOK, p.
|
|
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,
|
|
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,
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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,
|
|
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
|
|
338
|
+
`\t// TODO: mirror request fields from ${requestAppPkg}.${inputName} and add JSON/binding tags`,
|
|
280
339
|
`}`,
|
|
281
340
|
"",
|
|
282
|
-
`func to${inputName}(in ${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
|
|
344
|
+
`\treturn ${requestAppPkg}.${inputName}{}`,
|
|
286
345
|
`}`,
|
|
287
346
|
"",
|
|
288
347
|
].join("\n"));
|
package/package.json
CHANGED
|
@@ -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":
|
|
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
|
-
|
|
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.
|
|
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
|
}
|