@stjoseph/prumo 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/.prumo-templates/api/auth.md +130 -0
  2. package/.prumo-templates/api/config.md +85 -0
  3. package/.prumo-templates/api/errors.md +82 -0
  4. package/.prumo-templates/api/logging.md +79 -0
  5. package/.prumo-templates/api/modules.md +117 -0
  6. package/.prumo-templates/api/observability.md +91 -0
  7. package/.prumo-templates/api/pagination.md +71 -0
  8. package/.prumo-templates/api/routes.md +89 -0
  9. package/.prumo-templates/client/data.md +124 -0
  10. package/.prumo-templates/client/forms.md +88 -0
  11. package/.prumo-templates/core/conventions.md +74 -0
  12. package/.prumo-templates/core/testing.md +100 -0
  13. package/.prumo-templates/core/tooling.md +118 -0
  14. package/.prumo-templates/database/entities.md +97 -0
  15. package/.prumo-templates/database/migrations.md +74 -0
  16. package/.prumo-templates/database/queries.md +79 -0
  17. package/.prumo-templates/database/testing.md +82 -0
  18. package/.prumo-templates/database/transactions.md +68 -0
  19. package/.prumo-templates/mobile/components.md +83 -0
  20. package/.prumo-templates/mobile/config.md +84 -0
  21. package/.prumo-templates/mobile/routing.md +74 -0
  22. package/.prumo-templates/mobile/storage.md +84 -0
  23. package/.prumo-templates/mobile/structure.md +79 -0
  24. package/.prumo-templates/monorepo/dependencies.md +84 -0
  25. package/.prumo-templates/monorepo/layout.md +89 -0
  26. package/.prumo-templates/monorepo/tasks.md +76 -0
  27. package/.prumo-templates/multi-tenancy/client.md +83 -0
  28. package/.prumo-templates/multi-tenancy/isolation.md +104 -0
  29. package/.prumo-templates/site/rendering.md +83 -0
  30. package/.prumo-templates/site/structure.md +68 -0
  31. package/.prumo-templates/web/components.md +106 -0
  32. package/.prumo-templates/web/routing.md +82 -0
  33. package/.prumo-templates/web/structure.md +92 -0
  34. package/LICENSE +21 -0
  35. package/README.md +110 -0
  36. package/dist/asker.js +28 -0
  37. package/dist/cli.js +62 -0
  38. package/dist/compose.js +222 -0
  39. package/dist/context.js +38 -0
  40. package/dist/generate.js +60 -0
  41. package/dist/jsonc.js +76 -0
  42. package/dist/names.js +10 -0
  43. package/dist/questions.js +63 -0
  44. package/package.json +55 -0
  45. package/templates/api/.env.example +12 -0
  46. package/templates/api/.githooks/install.mjs +18 -0
  47. package/templates/api/.githooks/pre-commit +2 -0
  48. package/templates/api/.githooks/pre-push +2 -0
  49. package/templates/api/README.md +35 -0
  50. package/templates/api/biome.jsonc +18 -0
  51. package/templates/api/docker/init/01-auth-schema.sql +1 -0
  52. package/templates/api/docker-compose.yml +20 -0
  53. package/templates/api/gitignore +5 -0
  54. package/templates/api/migrations/Migration20260914123239_create_profile.ts +13 -0
  55. package/templates/api/nest-cli.json +10 -0
  56. package/templates/api/package.json +69 -0
  57. package/templates/api/pnpm-workspace.yaml +9 -0
  58. package/templates/api/src/app.module.ts +46 -0
  59. package/templates/api/src/auth/auth.constants.ts +2 -0
  60. package/templates/api/src/auth/auth.controller.ts +19 -0
  61. package/templates/api/src/auth/auth.factory.spec.ts +29 -0
  62. package/templates/api/src/auth/auth.factory.ts +52 -0
  63. package/templates/api/src/auth/auth.guard.ts +43 -0
  64. package/templates/api/src/auth/auth.module.ts +30 -0
  65. package/templates/api/src/auth/auth.ts +4 -0
  66. package/templates/api/src/auth/current-user.decorator.ts +9 -0
  67. package/templates/api/src/auth/express.d.ts +10 -0
  68. package/templates/api/src/auth/public.decorator.ts +4 -0
  69. package/templates/api/src/config/env.module.ts +11 -0
  70. package/templates/api/src/config/env.ts +81 -0
  71. package/templates/api/src/errors/problem-details.filter.ts +86 -0
  72. package/templates/api/src/errors/validation-exception.factory.ts +30 -0
  73. package/templates/api/src/health/health.controller.ts +32 -0
  74. package/templates/api/src/health/health.module.ts +9 -0
  75. package/templates/api/src/main.ts +43 -0
  76. package/templates/api/src/mikro-orm.config.ts +4 -0
  77. package/templates/api/src/mikro-orm.factory.ts +14 -0
  78. package/templates/api/src/request-context/request-context.module.ts +9 -0
  79. package/templates/api/src/request-context/request-context.service.ts +17 -0
  80. package/templates/api/src/request-context/request-id.middleware.ts +19 -0
  81. package/templates/api/src/users/dto/update-profile.dto.ts +20 -0
  82. package/templates/api/src/users/entities/profile.entity.ts +27 -0
  83. package/templates/api/src/users/users.controller.ts +23 -0
  84. package/templates/api/src/users/users.module.ts +10 -0
  85. package/templates/api/src/users/users.service.spec.ts +44 -0
  86. package/templates/api/src/users/users.service.ts +36 -0
  87. package/templates/api/test/factories/profile.factory.ts +26 -0
  88. package/templates/api/test/factories/user.factory.ts +21 -0
  89. package/templates/api/test/global-setup.ts +20 -0
  90. package/templates/api/test/setup.ts +80 -0
  91. package/templates/api/tsconfig.build.json +8 -0
  92. package/templates/api/tsconfig.json +29 -0
  93. package/templates/api/vitest.config.mts +17 -0
  94. package/templates/mobile/.env.example +1 -0
  95. package/templates/mobile/.githooks/install.mjs +18 -0
  96. package/templates/mobile/.githooks/pre-commit +2 -0
  97. package/templates/mobile/.githooks/pre-push +2 -0
  98. package/templates/mobile/README.md +30 -0
  99. package/templates/mobile/app.config.ts +13 -0
  100. package/templates/mobile/app.json +39 -0
  101. package/templates/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +3 -0
  102. package/templates/mobile/assets/expo.icon/Assets/grid.png +0 -0
  103. package/templates/mobile/assets/expo.icon/icon.json +35 -0
  104. package/templates/mobile/assets/images/android-icon-background.png +0 -0
  105. package/templates/mobile/assets/images/android-icon-foreground.png +0 -0
  106. package/templates/mobile/assets/images/android-icon-monochrome.png +0 -0
  107. package/templates/mobile/assets/images/icon.png +0 -0
  108. package/templates/mobile/assets/images/splash-icon.png +0 -0
  109. package/templates/mobile/babel.config.js +6 -0
  110. package/templates/mobile/biome.jsonc +21 -0
  111. package/templates/mobile/gitignore +11 -0
  112. package/templates/mobile/global.d.ts +1 -0
  113. package/templates/mobile/jest.config.js +21 -0
  114. package/templates/mobile/metro.config.js +4 -0
  115. package/templates/mobile/nativewind-env.d.ts +1 -0
  116. package/templates/mobile/package.json +63 -0
  117. package/templates/mobile/src/api-contract/api-error.ts +51 -0
  118. package/templates/mobile/src/api-contract/client.ts +26 -0
  119. package/templates/mobile/src/api-contract/index.ts +10 -0
  120. package/templates/mobile/src/api-contract/profile.ts +27 -0
  121. package/templates/mobile/src/app/(app)/_layout.tsx +18 -0
  122. package/templates/mobile/src/app/(app)/index.tsx +18 -0
  123. package/templates/mobile/src/app/(auth)/sign-in.tsx +24 -0
  124. package/templates/mobile/src/app/(auth)/sign-up.tsx +23 -0
  125. package/templates/mobile/src/app/_layout.tsx +54 -0
  126. package/templates/mobile/src/components/ui/button.tsx +36 -0
  127. package/templates/mobile/src/components/ui/field.tsx +30 -0
  128. package/templates/mobile/src/config.ts +3 -0
  129. package/templates/mobile/src/features/auth/auth-client.ts +27 -0
  130. package/templates/mobile/src/features/auth/intended-route.ts +11 -0
  131. package/templates/mobile/src/features/auth/session.ts +17 -0
  132. package/templates/mobile/src/features/auth/sign-in-form.spec.tsx +37 -0
  133. package/templates/mobile/src/features/auth/sign-in-form.tsx +80 -0
  134. package/templates/mobile/src/features/auth/sign-up-form.tsx +99 -0
  135. package/templates/mobile/src/features/clients/clients-context.tsx +21 -0
  136. package/templates/mobile/src/features/forms/form-error.tsx +16 -0
  137. package/templates/mobile/src/features/forms/server-errors.ts +33 -0
  138. package/templates/mobile/src/features/profile/profile-form.spec.tsx +58 -0
  139. package/templates/mobile/src/features/profile/profile-form.tsx +96 -0
  140. package/templates/mobile/src/features/storage/query-persister.ts +15 -0
  141. package/templates/mobile/src/features/storage/storage.ts +11 -0
  142. package/templates/mobile/src/global.css +3 -0
  143. package/templates/mobile/tailwind.config.js +9 -0
  144. package/templates/mobile/test/fake-transport.ts +23 -0
  145. package/templates/mobile/tsconfig.json +21 -0
  146. package/templates/site/.githooks/install.mjs +18 -0
  147. package/templates/site/.githooks/pre-commit +2 -0
  148. package/templates/site/.githooks/pre-push +2 -0
  149. package/templates/site/README.md +28 -0
  150. package/templates/site/biome.jsonc +12 -0
  151. package/templates/site/components.json +25 -0
  152. package/templates/site/gitignore +7 -0
  153. package/templates/site/next.config.ts +7 -0
  154. package/templates/site/package.json +40 -0
  155. package/templates/site/pnpm-workspace.yaml +5 -0
  156. package/templates/site/postcss.config.mjs +7 -0
  157. package/templates/site/src/app/globals.css +129 -0
  158. package/templates/site/src/app/layout.tsx +13 -0
  159. package/templates/site/src/app/page.spec.ts +10 -0
  160. package/templates/site/src/app/page.tsx +17 -0
  161. package/templates/site/src/lib/utils.ts +1 -0
  162. package/templates/site/tsconfig.json +34 -0
  163. package/templates/site/vitest.config.mts +8 -0
  164. package/templates/web/.env.example +1 -0
  165. package/templates/web/.githooks/install.mjs +18 -0
  166. package/templates/web/.githooks/pre-commit +2 -0
  167. package/templates/web/.githooks/pre-push +2 -0
  168. package/templates/web/README.md +29 -0
  169. package/templates/web/biome.jsonc +24 -0
  170. package/templates/web/components.json +25 -0
  171. package/templates/web/gitignore +6 -0
  172. package/templates/web/index.html +12 -0
  173. package/templates/web/package.json +55 -0
  174. package/templates/web/src/api-contract/api-error.ts +51 -0
  175. package/templates/web/src/api-contract/client.ts +26 -0
  176. package/templates/web/src/api-contract/index.ts +10 -0
  177. package/templates/web/src/api-contract/profile.ts +27 -0
  178. package/templates/web/src/app.spec.tsx +12 -0
  179. package/templates/web/src/app.tsx +14 -0
  180. package/templates/web/src/components/ui/button.tsx +57 -0
  181. package/templates/web/src/components/ui/field.tsx +224 -0
  182. package/templates/web/src/components/ui/input.tsx +19 -0
  183. package/templates/web/src/components/ui/label.tsx +17 -0
  184. package/templates/web/src/components/ui/separator.tsx +20 -0
  185. package/templates/web/src/config.ts +7 -0
  186. package/templates/web/src/features/auth/auth-client.ts +12 -0
  187. package/templates/web/src/features/auth/session.ts +17 -0
  188. package/templates/web/src/features/auth/sign-in-form.spec.tsx +30 -0
  189. package/templates/web/src/features/auth/sign-in-form.tsx +73 -0
  190. package/templates/web/src/features/auth/sign-up-form.tsx +79 -0
  191. package/templates/web/src/features/forms/form-error.tsx +15 -0
  192. package/templates/web/src/features/forms/server-errors.ts +33 -0
  193. package/templates/web/src/features/profile/profile-form.spec.tsx +58 -0
  194. package/templates/web/src/features/profile/profile-form.tsx +74 -0
  195. package/templates/web/src/index.css +130 -0
  196. package/templates/web/src/lib/utils.ts +1 -0
  197. package/templates/web/src/main.tsx +28 -0
  198. package/templates/web/src/routeTree.gen.ts +125 -0
  199. package/templates/web/src/router.tsx +21 -0
  200. package/templates/web/src/routes/__root.tsx +10 -0
  201. package/templates/web/src/routes/_authenticated/index.tsx +21 -0
  202. package/templates/web/src/routes/_authenticated.tsx +15 -0
  203. package/templates/web/src/routes/sign-in.tsx +20 -0
  204. package/templates/web/src/routes/sign-up.tsx +17 -0
  205. package/templates/web/test/fake-transport.ts +23 -0
  206. package/templates/web/test/render-app.tsx +17 -0
  207. package/templates/web/test/setup.ts +7 -0
  208. package/templates/web/tsconfig.json +21 -0
  209. package/templates/web/vite.config.ts +18 -0
  210. package/templates/workspace/.githooks/install.mjs +18 -0
  211. package/templates/workspace/.githooks/pre-commit +2 -0
  212. package/templates/workspace/.githooks/pre-push +2 -0
  213. package/templates/workspace/README.md +25 -0
  214. package/templates/workspace/biome.jsonc +8 -0
  215. package/templates/workspace/gitignore +5 -0
  216. package/templates/workspace/package.json +17 -0
  217. package/templates/workspace/packages/api-contract/package.json +20 -0
  218. package/templates/workspace/packages/api-contract/tsconfig.json +11 -0
  219. package/templates/workspace/pnpm-workspace.yaml +3 -0
  220. package/templates/workspace/tsconfig.base.json +10 -0
@@ -0,0 +1,130 @@
1
+ # Authentication
2
+
3
+ ## Rule
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()`.
9
+
10
+ Register the authentication guard globally. Every route is protected.
11
+
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*.
14
+
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.
18
+
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.
21
+
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.
29
+
30
+ ## Rationale
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
37
+ `{ message, code }`, not as problem+json. The client converts it; the server does not rewrite another
38
+ library's responses.
39
+
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.
48
+
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.
53
+
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.
57
+
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.
62
+
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.
74
+
75
+ ## Applies to
76
+
77
+ Every controller and every service. The guard, the Better Auth controller and its connection are
78
+ registered once at bootstrap.
79
+
80
+ ## Examples
81
+
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
+ ```
104
+
105
+ Receiving the session:
106
+
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') }
117
+ ```
118
+
119
+ ## Enforcement
120
+
121
+ **Boot.** The guard is global, so a route cannot be unprotected by omission, only by an explicit
122
+ `@Public()`.
123
+
124
+ **The database.** A row referencing a user that does not exist is refused.
125
+
126
+ **The migration script.** One command runs Better Auth's migrations and then ours, so the order cannot be
127
+ forgotten.
128
+
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.
@@ -0,0 +1,85 @@
1
+ # Configuration
2
+
3
+ ## Rule
4
+
5
+ Validate the environment with `@nestjs/config`'s `validate` function, using class-validator. Do not
6
+ add Joi.
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.
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.
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.
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`.
19
+
20
+ Read `process.env` in exactly one place: the environment module. Nowhere else.
21
+
22
+ ## Rationale
23
+
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.
28
+
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.
33
+
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
41
+ exception is written down so nobody harmonises it later.
42
+
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*.
48
+
49
+ ## Applies to
50
+
51
+ The configuration module and every consumer of a setting.
52
+
53
+ ## Examples
54
+
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
+ ```
74
+ ✅ // only inside the environment module
75
+ ❌ const url = process.env.DATABASE_URL
76
+ ```
77
+
78
+ ## Enforcement
79
+
80
+ **Boot.** The application does not start with an invalid environment, so a missing variable cannot
81
+ reach a request.
82
+
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.
@@ -0,0 +1,82 @@
1
+ # Errors
2
+
3
+ ## Rule
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.
8
+
9
+ Register one global exception filter, declared with a bare `@Catch()` so it receives everything.
10
+
11
+ Respond with `application/problem+json` per RFC 9457: `type`, `title`, `status`, `detail`, `instance`.
12
+
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`.
15
+
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.
18
+
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.
22
+
23
+ ## Rationale
24
+
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.
31
+
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.
37
+
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.
41
+
42
+ ## Applies to
43
+
44
+ Every route. The filter, the `exceptionFactory` and the request-id middleware are registered once at
45
+ bootstrap.
46
+
47
+ ## Examples
48
+
49
+ A validation failure:
50
+
51
+ ```
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"] }
58
+ ```
59
+
60
+ Raising an error:
61
+
62
+ ```
63
+ ✅ throw new NotFoundException()
64
+ ❌ throw new ProblemDetails({ status: 404, title: 'Not Found' })
65
+ ```
66
+
67
+ An unhandled exception:
68
+
69
+ ```
70
+ ✅ { "type": "about:blank", "title": "Internal Server Error", "status": 500,
71
+ "requestId": "01J..." }
72
+
73
+ ❌ { "status": 500, "detail": "null value in column \"tenant_id\" of relation \"orders\"" }
74
+ ```
75
+
76
+ ## Enforcement
77
+
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.
80
+
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.
@@ -0,0 +1,79 @@
1
+ # Logging
2
+
3
+ ## Rule
4
+
5
+ Log through `nestjs-pino`. Do not use Nest's default logger directly and do not add a second logging
6
+ library.
7
+
8
+ Write JSON to stdout. Render it with `pino-pretty` in development only, as a dev dependency. Never
9
+ write a log file.
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.
14
+
15
+ Never log a request or response body. Configure pino's `redact` for `authorization`, `cookie`,
16
+ `set-cookie`, `password`, `token` and `secret`.
17
+
18
+ Set the level from `LOG_LEVEL`, defaulting to `info`:
19
+
20
+ - `error`: it broke and somebody has to look
21
+ - `warn`: it degraded and continued: a retry, a fallback, a limit reached
22
+ - `info`: an event that matters to the business
23
+ - `debug`: off in production
24
+
25
+ A handled 4xx is not an error. Log it at `info`.
26
+
27
+ ## Rationale
28
+
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.
32
+
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.
37
+
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.
42
+
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.
49
+
50
+ ## Applies to
51
+
52
+ Every module. The logger, redaction and route exclusions are configured once at bootstrap.
53
+
54
+ ## Examples
55
+
56
+ Choosing a level:
57
+
58
+ ```
59
+ ✅ warn payment retried after gateway timeout
60
+ ❌ error validation failed: email must be an email
61
+ ```
62
+
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
68
+ ```
69
+
70
+ ## Enforcement
71
+
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`.
77
+
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.
@@ -0,0 +1,117 @@
1
+ # Modules
2
+
3
+ ## Rule
4
+
5
+ Create one module per resource: `users`, `orders`, `invoices`.
6
+
7
+ A module contains exactly what `nest g resource` produces: `*.module.ts`, `*.controller.ts`,
8
+ `*.service.ts`, `dto/`, `entities/` and the co-located spec files. Add nothing to that set, **except further
9
+ services**.
10
+
11
+ Add a service to a module when a group of its methods has a name of its own that the resource's name does
12
+ not cover: `order-refunds`, `order-shipping`. Generate it with `nest g service <name> <module> --flat`, beside
13
+ the resource's service. Do not split by line count, and do not create a `services/` directory or one service
14
+ per action.
15
+
16
+ Two services in one module never inject each other in both directions.
17
+
18
+ Export the service. Mark every method that serves only its own module `private`.
19
+
20
+ Reach another module through `imports: [OtherModule]` and inject what it exports. Reference another
21
+ module's entity when a relation requires it.
22
+
23
+ Two modules never depend on each other. When they appear to, move the common part into a third
24
+ module. Do not use `forwardRef`.
25
+
26
+ Do not use barrel files inside a module.
27
+
28
+ Anything serving more than one module becomes its own module, named for what it does:
29
+ `notifications`, `pdf`, `storage`. There is no `SharedModule`.
30
+
31
+ A pure function is a function in a file, imported directly. It gets no module.
32
+
33
+ ## Rationale
34
+
35
+ Nest supplies the mechanism and decides none of this, so every rule above exists to remove a choice
36
+ that would otherwise be made differently in each module and by each assistant.
37
+
38
+ One module per resource is the shape `nest g resource` generates and the shape every Nest example
39
+ already has, so the generator and the convention never disagree and a generated module needs no
40
+ correction afterwards. Designing an anatomy instead is what previously cost thirteen files per
41
+ feature, five of which carried no guarantee.
42
+
43
+ `private` makes the compiler enforce the module's surface, at no cost. A cycle is refused because
44
+ Nest's own guidance is to avoid one: `forwardRef` is its escape hatch, and its documented caveats
45
+ include an indeterminate order of instantiation, the class of failure that appears once in
46
+ production and does not reproduce. Barrel files are refused because Nest names them as a way to
47
+ create a cycle nobody can see.
48
+
49
+ **The extra service exists for the module that is large and still one thing.** Splitting into another
50
+ module is right when a second responsibility has crept in, and wrong when it has not: two modules sharing the
51
+ same entities and invariants end up wanting to call each other, which is the cycle refused above. Nest's own
52
+ answer is several providers in one module, and `nest g service <name> <module>` both creates the file and
53
+ registers it, so the generator and the convention still agree.
54
+
55
+ The criterion is a name rather than a size because a line limit ages and invites gaming: somebody moves
56
+ arbitrary methods out to pass the number, and the result is worse than the long file. `--flat` keeps the
57
+ anatomy one level deep. One service per action is refused on evidence: it is what produced thirteen files
58
+ per feature.
59
+
60
+ **What this costs:** the split is a judgement, held by review. And a module with several services can form a
61
+ cycle inside itself, which is why the cycle rule now reaches between services as well as between modules.
62
+
63
+ A `SharedModule` has no admission criterion, so it only grows. Requiring a name forces the question
64
+ *what is this*, and a module nobody can name should not exist.
65
+
66
+ ## Applies to
67
+
68
+ Every module under `src/`. The rules about reaching across modules apply wherever one module's code
69
+ refers to another's.
70
+
71
+ ## Examples
72
+
73
+ Exporting the service, with internals closed:
74
+
75
+ ```
76
+ ✅ export class UsersService {
77
+ findById(id: string) {}
78
+ private normaliseEmail(v: string) {}
79
+ }
80
+
81
+ ❌ export class UsersService {
82
+ findById(id: string) {}
83
+ normaliseEmail(v: string) {} // reachable from any module that injects this
84
+ }
85
+ ```
86
+
87
+ Growing a module that is still one thing:
88
+
89
+ ```
90
+ ✅ orders/orders.service.ts
91
+ orders/order-refunds.service.ts // nest g service order-refunds orders --flat
92
+ ❌ orders/services/cancel-order.service.ts
93
+ ❌ refunds/refunds.module.ts // needs orders' entities, and orders needs it back
94
+ ```
95
+
96
+ Reaching another module:
97
+
98
+ ```
99
+ ✅ @Module({ imports: [UsersModule] })
100
+ ❌ @Module({ imports: [forwardRef(() => UsersModule)] })
101
+ ```
102
+
103
+ Importing inside a module:
104
+
105
+ ```
106
+ ✅ import { UsersService } from './users.service'
107
+ ❌ import { UsersService } from './index'
108
+ ```
109
+
110
+ ## Enforcement
111
+
112
+ **Lint.** Barrel-file imports inside a module are verifiable and are caught by a lint rule.
113
+
114
+ **Review only.** Everything else. Nothing checks that a module maps to a resource, that an extra service
115
+ has a name of its own rather than an arbitrary slice, that a method that
116
+ should be `private` is, or that a `SharedModule` has not been created; a reviewer does. `private`
117
+ prevents accidental injection; it does not prevent someone widening the method next week to reach it.
@@ -0,0 +1,91 @@
1
+ # Observability
2
+
3
+ ## Rule
4
+
5
+ Expose two health routes with `@nestjs/terminus`. Liveness reports that the process is up and checks
6
+ no dependency. Readiness checks that dependencies answer, the database included. Mark both `@Public()`
7
+ and exclude them from request logging.
8
+
9
+ Connect the ORM explicitly at bootstrap, before the application listens.
10
+
11
+ Do not install an error-reporting or tracing service by default. A project has structured logs and
12
+ these two routes, and nothing else, until its owner chooses otherwise.
13
+
14
+ When Sentry is chosen, wire it exactly as follows and not otherwise.
15
+
16
+ Import `instrument.ts` as the first line of the bootstrap, before `NestFactory` and before every other
17
+ import. Register `SentryModule` in the root module. Add `@SentryExceptionCaptured()` to the existing
18
+ global exception filter's `catch()` method. Do not register `SentryGlobalFilter`.
19
+
20
+ Send an event for any 5xx response: an unhandled exception and a deliberately thrown 5xx alike. Never
21
+ send one for a 4xx, and never make a `logger.error` call create one by itself.
22
+
23
+ Attach the request id as a **tag**, and the user's identifier when a session exists. Leave PII sending
24
+ off. Never attach a request body.
25
+
26
+ ## Rationale
27
+
28
+ Liveness and readiness answer opposite questions (*should I restart this process* and *should I send
29
+ it traffic*) and a single endpoint forces one answer to both. The failure that causes is routine: the
30
+ endpoint checks the database, the database wobbles, and the orchestrator restarts a healthy process.
31
+ Restarting does not fix a database, and the wobble becomes an outage.
32
+
33
+ **MikroORM 7's `init` no longer connects**, and the readiness check asks whether a connection exists. Without
34
+ the explicit connect, readiness reports the database down until a first query, and an orchestrator sends no
35
+ query to an instance that is not ready. The instance never becomes ready. Connecting at bootstrap also means
36
+ an unreachable database fails the boot instead of a request.
37
+
38
+ Error reporting is not installed because it requires a commercial account. Installing a service that
39
+ needs an account, a DSN and eventually a bill decides something commercial on the owner's behalf,
40
+ inside a project that is theirs. Free tooling can simply be deleted; a SaaS dependency is different in
41
+ kind. So the wiring is written down and ready, and absent until wanted.
42
+
43
+ `Sentry.init` must run before other modules load, because the instrumentation patches them as they are
44
+ imported. Nothing enforces that ordering, which makes it the most fragile line in the project.
45
+
46
+ The 5xx boundary is the status, not the exception type: a deliberately thrown `ServiceUnavailableException`
47
+ is a real failure somebody must see, and treating it as routine because it was explicit would hide the
48
+ case that matters most. Sending everything instead turns the issue list into a stream of invalid client
49
+ input until somebody mutes the project.
50
+
51
+ **What reaches Sentry leaves your perimeter.** A log stays in infrastructure you control; a third-party
52
+ service does not, and is readable by anyone with access to that project. That is why the bar here is
53
+ higher than for logs, and why the body never goes: on an authentication route it is the password, on a
54
+ payment route it is the card. Reproduction is a convenience; a leak into someone else's system is an
55
+ incident.
56
+
57
+ ## Applies to
58
+
59
+ The bootstrap, the health module, and the global exception filter.
60
+
61
+ ## Examples
62
+
63
+ Health routes:
64
+
65
+ ```
66
+ ✅ GET /api/health/live → process is up, checks nothing
67
+ GET /api/health/ready → database answers
68
+ ❌ GET /api/health → used for both
69
+ ```
70
+
71
+ Wiring Sentry into the existing filter:
72
+
73
+ ```
74
+ ✅ @Catch()
75
+ export class ProblemFilter implements ExceptionFilter {
76
+ @SentryExceptionCaptured()
77
+ catch(exception: unknown, host: ArgumentsHost) {}
78
+ }
79
+
80
+ ❌ app.useGlobalFilters(new SentryGlobalFilter(), new ProblemFilter())
81
+ ```
82
+
83
+ ## Enforcement
84
+
85
+ **Boot.** The health routes and the filter are registered once.
86
+
87
+ **Review only, and this is the weakest line in the project:** that `instrument.ts` stays the first
88
+ import. Reordering imports for tidiness, or a formatter doing it unasked, silently stops the
89
+ instrumentation, and nothing fails.
90
+
91
+ Also review: that no event carries a body, and that a 4xx never becomes one.
@@ -0,0 +1,71 @@
1
+ # Pagination
2
+
3
+ ## Rule
4
+
5
+ Paginate every list endpoint by cursor. The UUID v7 primary key is the cursor.
6
+
7
+ Order by id descending. `after` means *older than this*. Do not accept a sort parameter.
8
+
9
+ Accept two query parameters, declared on a query DTO: `limit`, defaulting to 20 and carrying
10
+ `@Max(100)`, and `after`.
11
+
12
+ Return `{ "data": [...], "nextCursor": "01J..." | null }`. Add nothing else: no `hasMore`, no
13
+ `total`, no nested `meta`.
14
+
15
+ Set `nextCursor` to the id of the last item on the page, or `null` when the page is the last one.
16
+
17
+ ## Rationale
18
+
19
+ Offset pagination fails silently. Under concurrent inserts, page 2 repeats an item from page 1 or
20
+ skips one (no error, just a wrong list) and it degrades at depth, because the database counts and
21
+ discards every skipped row. A cursor is stable at any depth, and UUID v7 being time-ordered means the
22
+ id already is one: no extra sort column, no extra index.
23
+
24
+ That choice fixes the order. `WHERE id < :cursor ORDER BY id DESC` only works over one ordering, so a
25
+ sort parameter would break the cursor without erroring. Descending, because that is what a list screen
26
+ shows.
27
+
28
+ `hasMore` is omitted because a null `nextCursor` already says it, and a field derived from another
29
+ field can disagree with it. `total` is omitted because it costs a `COUNT` on every request: the most
30
+ expensive part of an otherwise cheap endpoint, paid whether or not anyone reads it. Adding it to one
31
+ route later breaks no client.
32
+
33
+ The parameters are an ordinary query DTO, so validation already covers them and the maximum is a
34
+ decorator rather than clamping logic. Clamping would return 100 rows to a client that asked for 500
35
+ without saying so, and the client would advance as though it had consumed 500.
36
+
37
+ ## Applies to
38
+
39
+ Every endpoint returning a collection. Filtering is not covered here: a filter is a query DTO field
40
+ like any other, and it composes with the cursor without special handling.
41
+
42
+ ## Examples
43
+
44
+ The response:
45
+
46
+ ```
47
+ ✅ { "data": [ ... ], "nextCursor": "01JQ8Z..." }
48
+ ❌ { "data": [ ... ], "meta": { "page": 2, "total": 3204, "hasMore": true } }
49
+ ```
50
+
51
+ The query DTO:
52
+
53
+ ```
54
+ ✅ @Max(100) limit: number = 20
55
+ ❌ limit: number = 20 // no maximum; ?limit=1000000 is a denial of service
56
+ ```
57
+
58
+ Ordering:
59
+
60
+ ```
61
+ ✅ GET /api/v1/orders?limit=20&after=01JQ8Z...
62
+ ❌ GET /api/v1/orders?sort=total&page=3
63
+ ```
64
+
65
+ ## Enforcement
66
+
67
+ **Validation.** `limit`'s bounds are enforced by the pipe, and an unknown parameter (`page`, `sort`,
68
+ `offset`) is already rejected because unknown properties are forbidden.
69
+
70
+ **Review only.** That `nextCursor` is the last item's id and is null on the final page, and that a new
71
+ list endpoint paginates at all rather than returning an unbounded array.