@stjoseph/prumo 0.0.6 → 0.1.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 (139) hide show
  1. package/.prumo-templates/api/auth.md +51 -95
  2. package/.prumo-templates/api/config.md +30 -56
  3. package/.prumo-templates/api/errors.md +64 -44
  4. package/.prumo-templates/api/logging.md +27 -44
  5. package/.prumo-templates/api/observability.md +36 -62
  6. package/.prumo-templates/api/pagination.md +23 -38
  7. package/.prumo-templates/api/routes.md +67 -48
  8. package/.prumo-templates/api/structure.md +130 -0
  9. package/.prumo-templates/client/data.md +4 -3
  10. package/.prumo-templates/core/conventions.md +10 -7
  11. package/.prumo-templates/core/testing.md +43 -26
  12. package/.prumo-templates/core/tooling.md +33 -30
  13. package/.prumo-templates/database/entities.md +53 -61
  14. package/.prumo-templates/database/queries.md +34 -47
  15. package/.prumo-templates/database/transactions.md +28 -38
  16. package/.prumo-templates/mcp/server.md +54 -0
  17. package/.prumo-templates/monorepo/dependencies.md +3 -3
  18. package/.prumo-templates/monorepo/layout.md +1 -1
  19. package/README.md +6 -6
  20. package/dist/asker.js +4 -0
  21. package/dist/cli.js +4 -0
  22. package/dist/commands.js +2 -0
  23. package/dist/compose.js +6 -2
  24. package/dist/context.js +2 -0
  25. package/dist/generate.js +8 -0
  26. package/dist/mcp.js +100 -0
  27. package/dist/questions.js +21 -1
  28. package/package.json +4 -3
  29. package/templates/api/.swcrc +13 -0
  30. package/templates/api/biome.jsonc +5 -3
  31. package/templates/api/package.json +21 -27
  32. package/templates/api/pnpm-workspace.yaml +0 -2
  33. package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
  34. package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
  35. package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
  36. package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
  37. package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
  38. package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
  39. package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
  40. package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
  41. package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
  42. package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
  43. package/templates/api/src/infra/auth/auth.cli.ts +5 -0
  44. package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
  45. package/templates/api/src/infra/config/env.ts +47 -0
  46. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
  47. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
  48. package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
  49. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
  50. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
  51. package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
  52. package/templates/api/src/infra/di/index.ts +32 -0
  53. package/templates/api/src/infra/di/users.di.ts +7 -0
  54. package/templates/api/src/infra/http/app.ts +99 -0
  55. package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
  56. package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
  57. package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
  58. package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
  59. package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
  60. package/templates/api/src/infra/http/errors/http-error.ts +8 -0
  61. package/templates/api/src/infra/http/fastify.d.ts +17 -0
  62. package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
  63. package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
  64. package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
  65. package/templates/api/src/infra/http/server.ts +34 -0
  66. package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
  67. package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
  68. package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
  69. package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
  70. package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
  71. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
  72. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
  73. package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
  74. package/templates/api/test/infra/http/app.spec.ts +21 -0
  75. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
  76. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
  77. package/templates/api/test/support/factories/profile.factory.ts +25 -0
  78. package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
  79. package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
  80. package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
  81. package/templates/api/tsconfig.json +3 -6
  82. package/templates/api/vitest.config.mts +4 -4
  83. package/templates/mobile/jest.config.js +1 -1
  84. package/templates/mobile/package.json +2 -2
  85. package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
  86. package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  87. package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
  88. package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  89. package/templates/mobile/tsconfig.json +2 -1
  90. package/templates/site/{src → test}/app/page.spec.ts +1 -1
  91. package/templates/site/tsconfig.json +2 -1
  92. package/templates/site/vitest.config.mts +4 -2
  93. package/templates/web/package.json +4 -2
  94. package/templates/web/src/features/auth/auth-client.ts +5 -0
  95. package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
  96. package/templates/web/src/features/oauth/consent-form.tsx +63 -0
  97. package/templates/web/src/routeTree.gen.ts +21 -2
  98. package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
  99. package/templates/web/{src → test}/app.spec.tsx +2 -2
  100. package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  101. package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
  102. package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  103. package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
  104. package/templates/web/tsconfig.json +2 -1
  105. package/templates/web/vite.config.ts +3 -3
  106. package/.prumo-templates/api/modules.md +0 -117
  107. package/templates/api/nest-cli.json +0 -10
  108. package/templates/api/src/app.module.ts +0 -46
  109. package/templates/api/src/auth/auth.constants.ts +0 -2
  110. package/templates/api/src/auth/auth.controller.ts +0 -19
  111. package/templates/api/src/auth/auth.guard.ts +0 -43
  112. package/templates/api/src/auth/auth.module.ts +0 -30
  113. package/templates/api/src/auth/auth.ts +0 -4
  114. package/templates/api/src/auth/current-user.decorator.ts +0 -9
  115. package/templates/api/src/auth/express.d.ts +0 -10
  116. package/templates/api/src/auth/public.decorator.ts +0 -4
  117. package/templates/api/src/config/env.module.ts +0 -11
  118. package/templates/api/src/config/env.ts +0 -81
  119. package/templates/api/src/errors/problem-details.filter.ts +0 -86
  120. package/templates/api/src/errors/validation-exception.factory.ts +0 -30
  121. package/templates/api/src/health/health.controller.ts +0 -32
  122. package/templates/api/src/health/health.module.ts +0 -9
  123. package/templates/api/src/main.ts +0 -43
  124. package/templates/api/src/mikro-orm.factory.ts +0 -14
  125. package/templates/api/src/request-context/request-context.module.ts +0 -9
  126. package/templates/api/src/request-context/request-context.service.ts +0 -17
  127. package/templates/api/src/request-context/request-id.middleware.ts +0 -19
  128. package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
  129. package/templates/api/src/users/entities/profile.entity.ts +0 -27
  130. package/templates/api/src/users/users.controller.ts +0 -23
  131. package/templates/api/src/users/users.module.ts +0 -10
  132. package/templates/api/src/users/users.service.spec.ts +0 -44
  133. package/templates/api/src/users/users.service.ts +0 -36
  134. package/templates/api/test/factories/profile.factory.ts +0 -26
  135. package/templates/api/tsconfig.build.json +0 -8
  136. /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
  137. /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  138. /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  139. /package/templates/web/test/{setup.ts → support/setup.ts} +0 -0
@@ -2,129 +2,85 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Mount Better Auth on a Nest controller: a catch-all route `@All('*path')` under `auth`, declared
6
- `version: VERSION_NEUTRAL`, passing the request and response to `toNodeHandler(auth)`. With the global `api`
7
- prefix this resolves to `/api/auth/*`. Mark that controller `@Public()`. It is the one controller that takes
8
- `@Req()` and `@Res()`.
5
+ Mount Better Auth at `/api/auth/*` with the catch-all route in `src/infra/http/controllers/auth.controller.ts`,
6
+ which rebuilds the request for `auth.handler`, as Better Auth's Fastify guide does. Mark it
7
+ `config: { public: true }`.
9
8
 
10
- Register the authentication guard globally. Every route is protected.
9
+ Every route is protected by the global `preHandler` hook in `src/infra/http/hooks/auth.hook.ts`. Mark a route
10
+ that needs no session with `config: { public: true }`; that means only *do not answer 401*, and the session is
11
+ still read.
11
12
 
12
- Declare a route that does not require a session with `@Public()`. There is no third state: the guard
13
- always attempts to resolve the session, and `@Public()` means only *do not return 401*.
13
+ Read the user in the handler with `currentUser(request)` and pass its id to the use case as an argument. A use
14
+ case never reads the request or any ambient session.
14
15
 
15
- Read the session with a `@CurrentUser()` parameter decorator. Pass it to the service as an ordinary
16
- argument. Do not inject a request-scoped session provider, do not take `@Req()`, and do not read the
17
- session from ambient context inside a service.
16
+ Keep Better Auth's tables in their own Postgres schema, `auth`, on their own connection, migrated by its own
17
+ CLI from the compiled `dist/infra/auth/auth.cli.js`, before our migrations. `pnpm db:migrate` does both, in
18
+ that order.
18
19
 
19
- Keep Better Auth's tables in their own Postgres schema, on their own connection, migrated by its own
20
- CLI. Point that CLI at the **compiled** configuration, and run its migrations **before** ours.
20
+ Set `advanced.database.generateId` to `'uuid'`. Reference `auth.user(id)` from an application table with a
21
+ real foreign key and `ON DELETE RESTRICT`, declared in its schema as a to-one relation to `AuthUser` with
22
+ `mapToPk: true`, so the domain still holds a plain `userId`. `AuthUser`, in `auth-user.schema.ts`, maps only
23
+ the id and is the one Better Auth table the ORM knows. Keep `schemaGenerator.ignoreSchema: ['auth']` and
24
+ `skipTables: ['auth.user']` in `mikro-orm.factory.ts`. Better Auth's tables follow Better Auth's conventions,
25
+ not `database/entities.md`.
21
26
 
22
- Set `advanced.database.generateId` to `'uuid'`.
23
-
24
- Better Auth's tables follow Better Auth's conventions, not `database/entities.md`. Reference `auth.user(id)`
25
- from an application table with a real foreign key and `ON DELETE RESTRICT`.
26
-
27
- Create what the application keeps about a user in `databaseHooks.user.create.after`. Pass that hook into the
28
- factory that builds Better Auth, so the factory does not depend on Nest.
27
+ Create what the application keeps about a user in `databaseHooks.user.create.after`, through a use case:
28
+ `src/infra/di/index.ts` passes the hook into `createAuth`.
29
29
 
30
30
  ## Rationale
31
31
 
32
- `VERSION_NEUTRAL` keeps the auth routes out of the version segment, because you version what you own
33
- and the shape of these endpoints is Better Auth's contract, not this project's. Mounting inside Nest
34
- rather than as middleware ahead of the router means the logging and the request id apply to the
35
- authentication flow, the path where investigating matters most. **The exception filter does not:**
36
- `toNodeHandler` writes Better Auth's own responses, so an authentication error arrives as
32
+ The route sits inside Fastify, so the request id and the logs cover the authentication flow. **The error
33
+ handler does not:** Better Auth writes its own responses, so an authentication error arrives as
37
34
  `{ message, code }`, not as problem+json. The client converts it; the server does not rewrite another
38
35
  library's responses.
39
36
 
40
- `toNodeHandler` rather than `auth.handler(req)`, because `handler` expects a Fetch API `Request` and Nest
41
- hands the controller an Express one; `toNodeHandler` is Better Auth's own adapter between the two. Running it
42
- after Nest's body parser was expected to break sign-up and does not: verified with a real sign-up through the
43
- controller.
44
-
45
- The CLI reads the compiled file because it loads TypeScript through its own transpiler, which cannot handle
46
- the legacy property decorators the validated environment class uses. Migrations before ours, because an
47
- application table references Better Auth's.
37
+ Protected is the default because of the asymmetry between the two mistakes. A route someone forgot to
38
+ protect is open, and nothing breaks. A route someone forgot to mark public answers 401 on the first call and
39
+ is fixed in seconds.
48
40
 
49
- **Its ids are `text` by default**, which no application column typed `uuid` can reference. `generateId:
50
- 'uuid'` makes them `uuid` columns. What remains different is deliberate and not ours to fix: camelCase column
51
- names, and `gen_random_uuid()` (version 4) rather than `uuidv7()`. Rewriting another library's schema is
52
- a migration fighting its CLI on every upgrade.
41
+ The user arrives as an argument so a use case's signature says what it needs, and its test passes an id
42
+ instead of building a request.
53
43
 
54
- The foreign key into `auth.user` exists because between the database guaranteeing and the application
55
- remembering, this project chooses the database. **What it costs:** deleting a user fails while rows reference
56
- it, so the application removes them first, deliberately.
44
+ Better Auth's ids are `text` by default, which no `uuid` column can reference; `generateId: 'uuid'` fixes
45
+ that. What stays different is not ours to fix: camelCase columns and `gen_random_uuid()`. Rewriting another
46
+ library's schema fights its CLI on every upgrade. Better Auth's migrations run first because an application
47
+ table references its tables.
57
48
 
58
- The hook is Better Auth's extension point for work that must follow sign-up, and it is the only place where a
59
- client never learns that identity and profile are two tables. **It is not atomic:** the user and the
60
- application's row are written over two connections, so a failure between them leaves a user with no row. The
61
- result is a visible 404, not corrupted data.
49
+ The ORM compares its entities with everything in the database, so without `ignoreSchema` it reads Better
50
+ Auth's tables as leftovers and every generated migration drops them, with every login in them. The foreign
51
+ key is declared rather than written by hand in a migration because the ORM would otherwise drop that too,
52
+ every time. `AuthUser` exists only as that anchor.
62
53
 
63
- The guard is global because of the asymmetry between the two possible mistakes. A forgotten guard
64
- leaves an endpoint open: nothing errors, nothing breaks, and the route test still passes, because
65
- route tests rarely assert refusal. A forgotten `@Public()` returns 401 on the first call and is fixed
66
- in seconds.
67
-
68
- The session resolves even on public routes so that "works signed out, shows more signed in" needs no
69
- third decorator. With no cookie there is no lookup, so an anonymous request to a public endpoint pays
70
- nothing.
71
-
72
- The session arrives as an argument so that a service's signature says what it needs. Ambient context
73
- would hide the dependency and force every test to construct a context before it can run.
54
+ **The sign-up hook is not atomic:** the user and the application's row are written over two connections, so
55
+ a failure between them leaves a user without a profile. The result is a visible 404, not corrupted data. And
56
+ the foreign key means deleting a user fails while rows reference it, so the application removes them first.
74
57
 
75
58
  ## Applies to
76
59
 
77
- Every controller and every service. The guard, the Better Auth controller and its connection are
78
- registered once at bootstrap.
60
+ Every route, every use case that acts for a user, and `src/infra/auth/`, where Better Auth is built.
79
61
 
80
62
  ## Examples
81
63
 
82
- Declaring access:
83
-
84
- ```
85
- ✅ @Public() @Get('health') health() {}
86
- ❌ @Get('health') health() {} // 401, because protected is the default
87
- ```
88
-
89
- Mounting the handler:
90
-
91
- ```
92
- ✅ @Public() @All('*path') handle(@Req() req: Request, @Res() res: Response) {
93
- return toNodeHandler(this.auth)(req, res)
94
- }
95
- ❌ @All('*') handle(@Req() req: Request) { return this.auth.handler(req) }
96
- ```
97
-
98
- Referencing a user from an application table:
99
-
100
- ```
101
- ✅ foreign key ("user_id") references "auth"."user" ("id") on delete restrict
102
- ❌ "user_id" uuid not null // nothing stops an orphan
103
64
  ```
65
+ ✅ app.get('/health', { config: { public: true } }, …)
66
+ ❌ app.get('/health', …) 401: protected is the default
104
67
 
105
- Receiving the session:
68
+ ✅ execute(currentUser(request).id)
69
+ ❌ execute(request)
106
70
 
107
- ```
108
- ✅ findMine(@CurrentUser() user: SessionUser) { return this.orders.forUser(user.id) }
109
- ❌ findMine(@Req() req: Request) { return this.orders.forUser(req.session.user.id) }
110
- ```
111
-
112
- Inside a service:
113
-
114
- ```
115
- ✅ forUser(userId: string) {}
116
- ❌ forUser() { const userId = context.get('userId') }
71
+ ✅ userId: { kind: 'm:1', entity: () => 'AuthUser' as never, mapToPk: true, deleteRule: 'restrict' }
72
+ ❌ userId: { type: 'uuid' } nothing stops an orphan
117
73
  ```
118
74
 
119
75
  ## Enforcement
120
76
 
121
- **Boot.** The guard is global, so a route cannot be unprotected by omission, only by an explicit
122
- `@Public()`.
77
+ **Boot.** The hook is global, so a route is unprotected only by an explicit `public: true`.
123
78
 
124
- **The database.** A row referencing a user that does not exist is refused.
79
+ **Tests.** `users.controller.spec.ts` signs up through the real route and proves 401 without a session.
80
+ `mikro-orm.factory.spec.ts` asserts that a migrated database leaves the schema generator nothing to change,
81
+ so a configuration that would drop Better Auth's schema, or the foreign key, fails the suite.
125
82
 
126
- **The migration script.** One command runs Better Auth's migrations and then ours, so the order cannot be
127
- forgotten.
83
+ **The database.** A row referencing a user that does not exist is refused.
128
84
 
129
- **Review only.** That each `@Public()` is deliberate. It is the one decorator in the system whose
130
- mistaken use is invisible at runtime: the route works, and it works for everyone.
85
+ **Review only.** That each `public: true` is deliberate: the one mistake that is invisible at runtime, because
86
+ the route works, for everyone.
@@ -2,84 +2,58 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Validate the environment with `@nestjs/config`'s `validate` function, using class-validator. Do not
6
- add Joi.
5
+ Validate the environment with the Zod schema in `src/infra/config/env.ts`. Convert a value that is not a
6
+ string with `z.coerce`. Do not add another configuration library.
7
7
 
8
- Register the validated class as a provider and inject it with its type. Do not call
9
- `ConfigService.get()` with a string key.
8
+ Register the parsed value in the container under `ENV` and inject it with the `Env` type. Nothing reads a
9
+ setting by a string key.
10
10
 
11
- Fail the boot when validation fails. Report every invalid variable at once, ignore unknown ones, and
12
- name the variable without printing its value.
11
+ Fail the boot when validation fails. Report every invalid variable at once, ignore unknown ones, and name the
12
+ variable without printing its value.
13
13
 
14
- Keep `.env` in `.gitignore`. Commit `.env.example` with every variable and a safe sample value. Do not
15
- read an env file in production.
14
+ Keep `.env` in `.gitignore`. Commit `.env.example` with every variable and a safe sample value. Do not read
15
+ an env file in production.
16
16
 
17
- Load `.env` outside production with Node's `process.loadEnvFile`, in the environment module, before
18
- validating. Give `ConfigModule` `ignoreEnvFile: true` and that loading function as its `validate`.
17
+ Load `.env` outside production with Node's `process.loadEnvFile`, inside `loadEnv()`, before validating.
19
18
 
20
- Read `process.env` in exactly one place: the environment module. Nowhere else.
19
+ Read `process.env` in exactly one place: `env.ts`.
21
20
 
22
21
  ## Rationale
23
22
 
24
- class-validator is already installed for DTOs, so using it here means one validation vocabulary
25
- instead of two doing the same kind of work. Joi is genuinely better at configuration schemas
26
- (coercion and defaults read more naturally there) but not enough to justify a second grammar in the
27
- same project.
23
+ Zod already validates every request, so the environment uses the same vocabulary. `Env` is inferred from the
24
+ schema, so a renamed variable breaks the build at every consumer instead of reading `undefined` in
25
+ production.
28
26
 
29
- Injecting the validated class rather than calling `ConfigService.get()` keeps the type honest. `get()`
30
- returns `string | undefined`, so every call site needs a `!` or a guard for a value that was already
31
- proven present at boot, and the key is a string nothing checks: rename the variable, miss one call,
32
- and it compiles and breaks in production. A class property breaks the build instead.
27
+ Reporting every failure at once turns eight missing variables into one cycle rather than eight. The value is
28
+ never printed because most of these are secrets, and a boot error goes to the orchestrator's log, which
29
+ usually has more readers than the database.
33
30
 
34
- Reporting every failure at once turns eight missing variables into one cycle rather than eight. The
35
- value is never printed because most of these are secrets and a boot error goes to the orchestrator's
36
- log, which usually has more readers than the database.
37
-
38
- **Unknown variables are ignored here, and that is deliberate.** Rejecting the unknown is the rule for
39
- request bodies and query parameters; a process environment legitimately carries `PATH`, `HOME` and
40
- whatever the CI runner sets, so the same rule would make the application impossible to start. This
31
+ **Unknown variables are ignored here, and that is deliberate.** Rejecting the unknown is the rule for request
32
+ bodies; a process environment legitimately carries `PATH`, `HOME` and whatever the CI runner sets. This
41
33
  exception is written down so nobody harmonises it later.
42
34
 
43
- **The file is loaded by the environment module rather than by `ConfigModule`** because the application is not
44
- the only entry point. The ORM's CLI and Better Auth's CLI both import the configuration long before any Nest
45
- module exists, so `ConfigModule`'s own loading reaches the application and neither CLI. Node's `--env-file`
46
- would do it without code, but it has to be typed on every command, and Node refuses it inside
47
- `NODE_OPTIONS`. One function every entry point calls keeps one policy, including *never in production*.
35
+ **The file is loaded by `loadEnv()` rather than by the server** because the server is not the only entry
36
+ point: the ORM's CLI and Better Auth's CLI both read the configuration without starting it. Node's
37
+ `--env-file` would work without code, but it has to be typed on every command. One function every entry
38
+ point calls keeps one policy, including *never in production*.
48
39
 
49
40
  ## Applies to
50
41
 
51
- The configuration module and every consumer of a setting.
42
+ `src/infra/config/env.ts` and every consumer of a setting.
52
43
 
53
44
  ## Examples
54
45
 
55
- Reading a setting:
56
-
57
- ```
58
- ✅ constructor(private readonly config: AppConfig) {}
59
- this.config.databaseUrl
60
-
61
- ❌ this.configService.get<string>('DATABASE_URL')!
62
- ```
63
-
64
- Reporting a bad value:
65
-
66
- ```
67
- ✅ DATABASE_URL is not a valid URL
68
- ❌ DATABASE_URL is not a valid URL: postgres://admin:hunter2@prod-db/app
69
- ```
70
-
71
- Reaching the environment:
72
-
73
46
  ```
74
- ✅ // only inside the environment module
47
+ ✅ constructor(@inject(ENV) private readonly env: Env) {}
75
48
  ❌ const url = process.env.DATABASE_URL
49
+
50
+ ✅ Invalid environment: DATABASE_URL, BETTER_AUTH_SECRET
51
+ ❌ DATABASE_URL is invalid: postgres://admin:hunter2@prod-db/app
76
52
  ```
77
53
 
78
54
  ## Enforcement
79
55
 
80
- **Boot.** The application does not start with an invalid environment, so a missing variable cannot
81
- reach a request.
56
+ **Boot.** The application does not start with an invalid environment.
82
57
 
83
- **Review only.** That `.env.example` gained the variable someone added to the config class, and that
84
- no error message quotes a value. A drifted example is caught the next time somebody clones, because
85
- boot lists everything missing.
58
+ **Review only.** That `.env.example` gained the variable someone added to the schema. A drifted example is
59
+ caught the next time somebody clones, because boot names everything missing.
@@ -2,81 +2,101 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Throw Nest's built-in exceptions: `NotFoundException`, `ForbiddenException`, `BadRequestException`
6
- and the rest. Do not define an exception hierarchy of your own, and do not construct a problem
7
- document anywhere but in the filter.
5
+ Throw from the domain a class extending `DomainError`, one per failure, in `domain/<module>/errors/`. Give
6
+ it a `kind` from the closed set and a `message` written for the client, because it becomes the `detail`
7
+ the client reads.
8
8
 
9
- Register one global exception filter, declared with a bare `@Catch()` so it receives everything.
9
+ | `kind` | Status |
10
+ |---|---|
11
+ | `not_found` | 404 |
12
+ | `conflict` | 409 |
13
+ | `invalid` | 422 |
14
+ | `forbidden` | 403 |
15
+ | `unauthorized` | 401 |
10
16
 
11
- Respond with `application/problem+json` per RFC 9457: `type`, `title`, `status`, `detail`, `instance`.
17
+ Map `kind` to a status in one place only: `src/infra/http/errors/error-handler.ts`. Throw `HttpError` only
18
+ from `src/infra/http`, for a condition that exists only in HTTP, such as a missing session. Do not return
19
+ an error response from a handler, and do not build a problem document anywhere but in the error handler.
12
20
 
13
- Carry validation failures in an `errors` extension member, keyed by field name. Use a dotted key for a
14
- nested field. Produce it from the `ValidationPipe`'s `exceptionFactory`.
21
+ Respond with `application/problem+json` per RFC 9457: `type`, `title`, `status`, `detail`, `instance`,
22
+ plus `requestId`.
15
23
 
16
- Give an unhandled exception a generic 500 problem document carrying nothing from the exception, in
17
- every environment. Send the exception itself to the log and to Sentry.
24
+ Answer a request the schema refuses with 400, and carry the fields in an `errors` extension member keyed by
25
+ field name, with a dotted key for a nested field. A field the schema does not know is named with the
26
+ message `Unrecognized key`.
18
27
 
19
- Generate a request id at the start of every request, propagate it with `AsyncLocalStorage`, return it
20
- in an `X-Request-Id` header on every response, and include it in every problem document. Reuse an
21
- inbound `X-Request-Id` rather than replacing it.
28
+ Give anything else a generic 500 problem document carrying nothing from the error, in every environment.
29
+ Send the error itself to the log, and to error reporting once it is installed.
30
+
31
+ Take the request id from an inbound `X-Request-Id`, or generate one, through Fastify's `requestIdHeader`
32
+ and `genReqId`. Return it in `X-Request-Id` on every response and include it in every problem document.
22
33
 
23
34
  ## Rationale
24
35
 
25
- Nest's own error bodies are not consistent: an `HttpException` produces `{ statusCode, message }` with
26
- `message` a string, while the `ValidationPipe` produces `{ statusCode, error, message }` with `message`
27
- an array. The field set and the type of a field both change, and every client that displays an error
28
- pays for it. Fixing that already requires a filter, and once a filter is being written a documented
29
- standard costs exactly what a bespoke shape costs, with the difference that clients, libraries and
30
- assistants already know the standard.
36
+ The domain says what went wrong and never how HTTP says it. A `kind` is a fact about the failure, *there is
37
+ no such profile*, which holds for an MCP tool as much as for a route; the status is HTTP's translation of
38
+ it, and lives with HTTP. A closed set keeps the translation total: the table is a
39
+ `Record<DomainErrorKind, number>`, so adding a `kind` without a status does not compile.
40
+
41
+ `invalid` is 422 rather than 400 so that a client can tell the two refusals apart without reading text:
42
+ 400 means the request had the wrong shape and says which fields, 422 means the shape was right and a rule
43
+ of the product refused it.
31
44
 
32
- The filter catches everything because otherwise the least predictable failures get the least
33
- predictable shape. A 500 says nothing because an unhandled exception's message carries whatever was
34
- nearby: a file path, a fragment of SQL, sometimes a connection string. It says the same thing in every
35
- environment because the error path is the least tested code in the system and does not need two
36
- versions of itself.
45
+ One handler builds every error body because otherwise the least predictable failures get the least
46
+ predictable shape. Fastify's own errors, the validator's and the domain's all pass through it, so a client
47
+ parses one format. Once a single shape is being written, a documented standard costs exactly what a bespoke
48
+ one costs, and clients, libraries and assistants already know it.
37
49
 
38
- That silence is only supportable because the request id makes it findable. The id covers the whole
39
- request rather than the moment of failure, since most investigations begin with what the request did
40
- before it threw.
50
+ A 500 says nothing because an unhandled error's message carries whatever was nearby: a file path, a
51
+ fragment of SQL, sometimes a connection string. It says the same thing in every environment because the
52
+ error path is the least tested code in the system and does not need two versions of itself.
53
+
54
+ That silence is only supportable because the request id makes it findable. Fastify writes the id into
55
+ every log line of the request, not only the one at the moment of failure, since most investigations begin
56
+ with what the request did before it threw.
41
57
 
42
58
  ## Applies to
43
59
 
44
- Every route. The filter, the `exceptionFactory` and the request-id middleware are registered once at
45
- bootstrap.
60
+ Every error class under `src/domain/`, every route, and `src/infra/http/errors/`, where the handler and the
61
+ not-found handler are registered once when the application is built.
46
62
 
47
63
  ## Examples
48
64
 
49
- A validation failure:
65
+ A domain error:
50
66
 
51
67
  ```
52
- ✅ { "type": "about:blank", "title": "Bad Request", "status": 400,
53
- "errors": { "email": ["must be an email"] },
54
- "requestId": "01J..." }
55
-
56
- ❌ { "statusCode": 400, "error": "Bad Request",
57
- "message": ["email must be an email"] }
68
+ ✅ class ProfileNotFoundError extends DomainError { readonly kind = 'not_found' }
69
+ ❌ class ProfileNotFoundError extends DomainError { readonly status = 404 }
70
+ ❌ throw new HttpError(404, 'Profile not found') from a use case
58
71
  ```
59
72
 
60
- Raising an error:
73
+ A validation failure:
61
74
 
62
75
  ```
63
- ✅ throw new NotFoundException()
64
- ❌ throw new ProblemDetails({ status: 404, title: 'Not Found' })
76
+ ✅ { "type": "about:blank", "title": "Bad Request", "status": 400, "detail": "Validation failed",
77
+ "errors": { "displayName": ["Too small: expected string to have >=1 characters"],
78
+ "role": ["Unrecognized key"] },
79
+ "requestId": "0199…" }
80
+
81
+ ❌ { "statusCode": 400, "code": "FST_ERR_VALIDATION", "message": "body/displayName Too small…" }
65
82
  ```
66
83
 
67
- An unhandled exception:
84
+ An unhandled error:
68
85
 
69
86
  ```
70
87
  ✅ { "type": "about:blank", "title": "Internal Server Error", "status": 500,
71
- "requestId": "01J..." }
88
+ "detail": "An unexpected error occurred.", "requestId": "0199…" }
72
89
 
73
90
  ❌ { "status": 500, "detail": "null value in column \"tenant_id\" of relation \"orders\"" }
74
91
  ```
75
92
 
76
93
  ## Enforcement
77
94
 
78
- **Boot.** The filter, the `exceptionFactory` and the request-id middleware are registered once, so no
79
- route can produce a different shape by accident.
95
+ **Typecheck.** The status table is a `Record<DomainErrorKind, number>`: a `kind` without a status does not
96
+ compile.
97
+
98
+ **Boot.** The error handler and the not-found handler are registered once when the application is built,
99
+ so no route produces a different shape by accident.
80
100
 
81
- **Review only.** That handlers throw Nest's exceptions rather than building responses by hand, and
82
- that no `detail` written by hand quotes something internal. Nothing checks either.
101
+ **Review only.** That the domain throws `DomainError` and never `HttpError`, that a handler throws rather
102
+ than answering an error itself, and that no `message` quotes something internal. Nothing checks these.
@@ -2,78 +2,61 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Log through `nestjs-pino`. Do not use Nest's default logger directly and do not add a second logging
6
- library.
5
+ Log through Fastify's built-in Pino: `request.log` inside a request, `app.log` outside one. Do not add a
6
+ second logging library.
7
7
 
8
- Write JSON to stdout. Render it with `pino-pretty` in development only, as a dev dependency. Never
9
- write a log file.
8
+ Write JSON to stdout. Render it with `pino-pretty` in development only, as a dev dependency. Never write a log
9
+ file. Tests run without a logger.
10
10
 
11
- Keep automatic request logging on. Put the request id on every line, taken from `nestjs-pino`'s
12
- per-request context, the same id returned in `X-Request-Id`. Exclude the health route by name, and
13
- the documentation route outside production.
11
+ Keep Fastify's request logging on. Every line of a request carries its id, the same one returned in
12
+ `X-Request-Id`. Silence the health routes with `logLevel: 'silent'`.
14
13
 
15
- Never log a request or response body. Configure pino's `redact` for `authorization`, `cookie`,
16
- `set-cookie`, `password`, `token` and `secret`.
14
+ Never log a request or response body. Keep the `redact` paths in `src/infra/http/app.ts` for the
15
+ `authorization`, `cookie` and `set-cookie` headers.
17
16
 
18
- Set the level from `LOG_LEVEL`, defaulting to `info`:
17
+ Set the level from `LOG_LEVEL`:
19
18
 
20
19
  - `error`: it broke and somebody has to look
21
20
  - `warn`: it degraded and continued: a retry, a fallback, a limit reached
22
21
  - `info`: an event that matters to the business
23
22
  - `debug`: off in production
24
23
 
25
- A handled 4xx is not an error. Log it at `info`.
24
+ A handled 4xx is not an error. The error handler logs only a 500.
26
25
 
27
26
  ## Rationale
28
27
 
29
- pino emits structured JSON natively, which is what an aggregator expects, and Nest's default logger
30
- prints prose for a human terminal. It also carries per-request context, so the request id needs no
31
- second storage mechanism alongside it.
28
+ Pino emits structured JSON, which is what an aggregator expects, and Fastify gives each request a child
29
+ logger carrying its id, so the id needs no second storage mechanism.
32
30
 
33
- Pretty-printing in development changes only how bytes are rendered for a person: the fields, values
34
- and structure are identical, and nothing in the system depends on the output format. That is why this
35
- environment difference is allowed where the error-response one is not; there, the content sent to a
36
- client changed.
31
+ Pretty-printing in development changes only how bytes are rendered for a person; nothing depends on the output
32
+ format. That is why this environment difference is allowed where the error-response one is not.
37
33
 
38
- Automatic logging exists because *did this request even arrive* is the first question in half of all
39
- investigations, and without it a successful request leaves no trace. Health checks are excluded
40
- because a probe every five seconds is seventeen thousand lines a day saying nothing is wrong, and that
41
- drowns search results and costs money at ingestion.
34
+ Request logging exists because *did this request even arrive* is the first question in half of all
35
+ investigations. Health checks are silenced because a probe every five seconds is seventeen thousand lines a
36
+ day saying nothing is wrong.
42
37
 
43
- The two secrecy rules cover different holes: bodies are the largest surface, and the automatic request
44
- log touches headers, where the session cookie lives.
45
-
46
- Levels carry a written rule because the names look self-explanatory and are not. When a 400 from a
47
- careless client is logged as `error`, the service's error rate stops measuring health, the alert fires
48
- constantly, and someone silences the alert that mattered.
38
+ Levels carry a written rule because the names look self-explanatory and are not. When a 400 from a careless
39
+ client is logged as `error`, the error rate stops measuring health, the alert fires constantly, and someone
40
+ silences the alert that mattered.
49
41
 
50
42
  ## Applies to
51
43
 
52
- Every module. The logger, redaction and route exclusions are configured once at bootstrap.
44
+ Every file that logs. The logger, redaction and silenced routes are configured when the application is built.
53
45
 
54
46
  ## Examples
55
47
 
56
- Choosing a level:
57
-
58
48
  ```
59
49
  ✅ warn payment retried after gateway timeout
60
50
  ❌ error validation failed: email must be an email
61
- ```
62
51
 
63
- Logging an object:
64
-
65
- ```
66
- ✅ logger.info({ userId: user.id }, 'subscription activated')
67
- ❌ logger.info({ user }, 'subscription activated') // whatever user carries goes too
52
+ ✅ request.log.info({ userId: user.id }, 'subscription activated')
53
+ ❌ request.log.info({ user }, 'subscription activated') whatever user carries goes too
68
54
  ```
69
55
 
70
56
  ## Enforcement
71
57
 
72
- **Configuration.** Redaction and the route exclusions are set once and cannot be bypassed per call
73
- site.
74
-
75
- **Review only.** That no one logs a whole object whose contents are unknown; `redact` covers named
76
- paths, so `{ user }` with a token inside defeats it entirely. And that a 4xx is not raised to `error`.
58
+ **Configuration.** Redaction and the silenced routes are set once.
77
59
 
78
- Access to the logs is a security boundary: an unhandled exception is sent there in full, so stack
79
- traces, SQL fragments and parameter values are legitimately present.
60
+ **Review only.** That no one logs a whole object whose contents are unknown: `redact` covers named paths, so
61
+ `{ user }` with a token inside defeats it. Access to the logs is a security boundary: an unhandled error is
62
+ sent there in full.