@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.
- package/.prumo-templates/api/auth.md +51 -95
- package/.prumo-templates/api/config.md +30 -56
- package/.prumo-templates/api/errors.md +64 -44
- package/.prumo-templates/api/logging.md +27 -44
- package/.prumo-templates/api/observability.md +36 -62
- package/.prumo-templates/api/pagination.md +23 -38
- package/.prumo-templates/api/routes.md +67 -48
- package/.prumo-templates/api/structure.md +130 -0
- package/.prumo-templates/client/data.md +4 -3
- package/.prumo-templates/core/conventions.md +10 -7
- package/.prumo-templates/core/testing.md +43 -26
- package/.prumo-templates/core/tooling.md +33 -30
- package/.prumo-templates/database/entities.md +53 -61
- package/.prumo-templates/database/queries.md +34 -47
- package/.prumo-templates/database/transactions.md +28 -38
- package/.prumo-templates/mcp/server.md +54 -0
- package/.prumo-templates/monorepo/dependencies.md +3 -3
- package/.prumo-templates/monorepo/layout.md +1 -1
- package/README.md +6 -6
- package/dist/asker.js +4 -0
- package/dist/cli.js +4 -0
- package/dist/commands.js +2 -0
- package/dist/compose.js +6 -2
- package/dist/context.js +2 -0
- package/dist/generate.js +8 -0
- package/dist/mcp.js +100 -0
- package/dist/questions.js +21 -1
- package/package.json +4 -3
- package/templates/api/.swcrc +13 -0
- package/templates/api/biome.jsonc +5 -3
- package/templates/api/package.json +21 -27
- package/templates/api/pnpm-workspace.yaml +0 -2
- package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
- package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
- package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
- package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
- package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
- package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
- package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
- package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
- package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
- package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
- package/templates/api/src/infra/auth/auth.cli.ts +5 -0
- package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
- package/templates/api/src/infra/config/env.ts +47 -0
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
- package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
- package/templates/api/src/infra/di/index.ts +32 -0
- package/templates/api/src/infra/di/users.di.ts +7 -0
- package/templates/api/src/infra/http/app.ts +99 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
- package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
- package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
- package/templates/api/src/infra/http/errors/http-error.ts +8 -0
- package/templates/api/src/infra/http/fastify.d.ts +17 -0
- package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
- package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
- package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
- package/templates/api/src/infra/http/server.ts +34 -0
- package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
- package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
- package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
- package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
- package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
- package/templates/api/test/infra/http/app.spec.ts +21 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
- package/templates/api/test/support/factories/profile.factory.ts +25 -0
- package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
- package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
- package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
- package/templates/api/tsconfig.json +3 -6
- package/templates/api/vitest.config.mts +4 -4
- package/templates/mobile/jest.config.js +1 -1
- package/templates/mobile/package.json +2 -2
- package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
- package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/mobile/tsconfig.json +2 -1
- package/templates/site/{src → test}/app/page.spec.ts +1 -1
- package/templates/site/tsconfig.json +2 -1
- package/templates/site/vitest.config.mts +4 -2
- package/templates/web/package.json +4 -2
- package/templates/web/src/features/auth/auth-client.ts +5 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
- package/templates/web/src/features/oauth/consent-form.tsx +63 -0
- package/templates/web/src/routeTree.gen.ts +21 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
- package/templates/web/{src → test}/app.spec.tsx +2 -2
- package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
- package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
- package/templates/web/tsconfig.json +2 -1
- package/templates/web/vite.config.ts +3 -3
- package/.prumo-templates/api/modules.md +0 -117
- package/templates/api/nest-cli.json +0 -10
- package/templates/api/src/app.module.ts +0 -46
- package/templates/api/src/auth/auth.constants.ts +0 -2
- package/templates/api/src/auth/auth.controller.ts +0 -19
- package/templates/api/src/auth/auth.guard.ts +0 -43
- package/templates/api/src/auth/auth.module.ts +0 -30
- package/templates/api/src/auth/auth.ts +0 -4
- package/templates/api/src/auth/current-user.decorator.ts +0 -9
- package/templates/api/src/auth/express.d.ts +0 -10
- package/templates/api/src/auth/public.decorator.ts +0 -4
- package/templates/api/src/config/env.module.ts +0 -11
- package/templates/api/src/config/env.ts +0 -81
- package/templates/api/src/errors/problem-details.filter.ts +0 -86
- package/templates/api/src/errors/validation-exception.factory.ts +0 -30
- package/templates/api/src/health/health.controller.ts +0 -32
- package/templates/api/src/health/health.module.ts +0 -9
- package/templates/api/src/main.ts +0 -43
- package/templates/api/src/mikro-orm.factory.ts +0 -14
- package/templates/api/src/request-context/request-context.module.ts +0 -9
- package/templates/api/src/request-context/request-context.service.ts +0 -17
- package/templates/api/src/request-context/request-id.middleware.ts +0 -19
- package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
- package/templates/api/src/users/entities/profile.entity.ts +0 -27
- package/templates/api/src/users/users.controller.ts +0 -23
- package/templates/api/src/users/users.module.ts +0 -10
- package/templates/api/src/users/users.service.spec.ts +0 -44
- package/templates/api/src/users/users.service.ts +0 -36
- package/templates/api/test/factories/profile.factory.ts +0 -26
- package/templates/api/tsconfig.build.json +0 -8
- /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
- /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
68
|
+
✅ execute(currentUser(request).id)
|
|
69
|
+
❌ execute(request)
|
|
106
70
|
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
122
|
-
`@Public()`.
|
|
77
|
+
**Boot.** The hook is global, so a route is unprotected only by an explicit `public: true`.
|
|
123
78
|
|
|
124
|
-
**
|
|
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
|
|
127
|
-
forgotten.
|
|
83
|
+
**The database.** A row referencing a user that does not exist is refused.
|
|
128
84
|
|
|
129
|
-
**Review only.** That each
|
|
130
|
-
|
|
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
|
|
6
|
-
add
|
|
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
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`,
|
|
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:
|
|
19
|
+
Read `process.env` in exactly one place: `env.ts`.
|
|
21
20
|
|
|
22
21
|
## Rationale
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
✅
|
|
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
|
|
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
|
|
84
|
-
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
9
|
+
| `kind` | Status |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `not_found` | 404 |
|
|
12
|
+
| `conflict` | 409 |
|
|
13
|
+
| `invalid` | 422 |
|
|
14
|
+
| `forbidden` | 403 |
|
|
15
|
+
| `unauthorized` | 401 |
|
|
10
16
|
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
21
|
+
Respond with `application/problem+json` per RFC 9457: `type`, `title`, `status`, `detail`, `instance`,
|
|
22
|
+
plus `requestId`.
|
|
15
23
|
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
predictable shape.
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
45
|
-
|
|
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
|
|
65
|
+
A domain error:
|
|
50
66
|
|
|
51
67
|
```
|
|
52
|
-
✅
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
73
|
+
A validation failure:
|
|
61
74
|
|
|
62
75
|
```
|
|
63
|
-
✅
|
|
64
|
-
|
|
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
|
|
84
|
+
An unhandled error:
|
|
68
85
|
|
|
69
86
|
```
|
|
70
87
|
✅ { "type": "about:blank", "title": "Internal Server Error", "status": 500,
|
|
71
|
-
"requestId": "
|
|
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
|
-
**
|
|
79
|
-
|
|
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
|
|
82
|
-
that no `
|
|
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
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
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.
|
|
16
|
-
`
|
|
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
|
|
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.
|
|
24
|
+
A handled 4xx is not an error. The error handler logs only a 500.
|
|
26
25
|
|
|
27
26
|
## Rationale
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
investigations
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
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.
|