@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
|
@@ -6,13 +6,13 @@ import { defineConfig } from 'vitest/config'
|
|
|
6
6
|
export default defineConfig({
|
|
7
7
|
plugins: [tanstackRouter({ target: 'react', autoCodeSplitting: true }), react(), tailwindcss()],
|
|
8
8
|
resolve: {
|
|
9
|
-
alias: { '@': `${import.meta.dirname}/src` },
|
|
9
|
+
alias: { '@': `${import.meta.dirname}/src`, '@test': `${import.meta.dirname}/test` },
|
|
10
10
|
},
|
|
11
11
|
test: {
|
|
12
12
|
environment: 'jsdom',
|
|
13
13
|
globals: true,
|
|
14
|
-
include: ['
|
|
15
|
-
setupFiles: ['./test/setup.ts'],
|
|
14
|
+
include: ['test/**/*.spec.{ts,tsx}'],
|
|
15
|
+
setupFiles: ['./test/support/setup.ts'],
|
|
16
16
|
coverage: { provider: 'v8', include: ['src/**'] },
|
|
17
17
|
},
|
|
18
18
|
})
|
|
@@ -1,117 +0,0 @@
|
|
|
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.
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { MikroOrmModule } from '@mikro-orm/nestjs'
|
|
2
|
-
import { PostgreSqlDriver } from '@mikro-orm/postgresql'
|
|
3
|
-
import { type MiddlewareConsumer, Module, type NestModule } from '@nestjs/common'
|
|
4
|
-
import { APP_FILTER } from '@nestjs/core'
|
|
5
|
-
import { LoggerModule } from 'nestjs-pino'
|
|
6
|
-
import { AuthModule } from '@/auth/auth.module'
|
|
7
|
-
import { Env } from '@/config/env'
|
|
8
|
-
import { EnvModule } from '@/config/env.module'
|
|
9
|
-
import { ProblemDetailsFilter } from '@/errors/problem-details.filter'
|
|
10
|
-
import { HealthModule } from '@/health/health.module'
|
|
11
|
-
import { createOrmConfig } from '@/mikro-orm.factory'
|
|
12
|
-
import { RequestContextModule } from '@/request-context/request-context.module'
|
|
13
|
-
import { RequestIdMiddleware } from '@/request-context/request-id.middleware'
|
|
14
|
-
import { UsersModule } from '@/users/users.module'
|
|
15
|
-
|
|
16
|
-
@Module({
|
|
17
|
-
imports: [
|
|
18
|
-
EnvModule,
|
|
19
|
-
LoggerModule.forRootAsync({
|
|
20
|
-
inject: [Env],
|
|
21
|
-
useFactory: (env: Env) => ({
|
|
22
|
-
pinoHttp: {
|
|
23
|
-
level: env.LOG_LEVEL,
|
|
24
|
-
redact: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
|
|
25
|
-
transport: env.NODE_ENV === 'development' ? { target: 'pino-pretty' } : undefined,
|
|
26
|
-
autoLogging: { ignore: (req) => req.url?.startsWith('/api/health') === true },
|
|
27
|
-
},
|
|
28
|
-
}),
|
|
29
|
-
}),
|
|
30
|
-
MikroOrmModule.forRootAsync({
|
|
31
|
-
inject: [Env],
|
|
32
|
-
driver: PostgreSqlDriver,
|
|
33
|
-
useFactory: (env: Env) => createOrmConfig(env),
|
|
34
|
-
}),
|
|
35
|
-
RequestContextModule,
|
|
36
|
-
AuthModule,
|
|
37
|
-
HealthModule,
|
|
38
|
-
UsersModule,
|
|
39
|
-
],
|
|
40
|
-
providers: [{ provide: APP_FILTER, useClass: ProblemDetailsFilter }],
|
|
41
|
-
})
|
|
42
|
-
export class AppModule implements NestModule {
|
|
43
|
-
configure(consumer: MiddlewareConsumer): void {
|
|
44
|
-
consumer.apply(RequestIdMiddleware).forRoutes('*path')
|
|
45
|
-
}
|
|
46
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { All, Controller, Inject, Req, Res, VERSION_NEUTRAL } from '@nestjs/common'
|
|
2
|
-
import { ApiExcludeController } from '@nestjs/swagger'
|
|
3
|
-
import { toNodeHandler } from 'better-auth/node'
|
|
4
|
-
import type { Request, Response } from 'express'
|
|
5
|
-
import { AUTH } from './auth.constants'
|
|
6
|
-
import type { Auth } from './auth.factory'
|
|
7
|
-
import { Public } from './public.decorator'
|
|
8
|
-
|
|
9
|
-
@ApiExcludeController()
|
|
10
|
-
@Controller({ path: 'auth', version: VERSION_NEUTRAL })
|
|
11
|
-
export class AuthController {
|
|
12
|
-
constructor(@Inject(AUTH) private readonly auth: Auth) {}
|
|
13
|
-
|
|
14
|
-
@Public()
|
|
15
|
-
@All('*path')
|
|
16
|
-
handle(@Req() request: Request, @Res() response: Response): Promise<void> {
|
|
17
|
-
return toNodeHandler(this.auth)(request, response)
|
|
18
|
-
}
|
|
19
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type CanActivate,
|
|
3
|
-
type ExecutionContext,
|
|
4
|
-
Inject,
|
|
5
|
-
Injectable,
|
|
6
|
-
UnauthorizedException,
|
|
7
|
-
} from '@nestjs/common'
|
|
8
|
-
import { Reflector } from '@nestjs/core'
|
|
9
|
-
import { fromNodeHeaders } from 'better-auth/node'
|
|
10
|
-
import type { Request } from 'express'
|
|
11
|
-
import { AUTH, IS_PUBLIC } from './auth.constants'
|
|
12
|
-
import type { Auth } from './auth.factory'
|
|
13
|
-
|
|
14
|
-
@Injectable()
|
|
15
|
-
export class AuthGuard implements CanActivate {
|
|
16
|
-
constructor(
|
|
17
|
-
private readonly reflector: Reflector,
|
|
18
|
-
@Inject(AUTH) private readonly auth: Auth,
|
|
19
|
-
) {}
|
|
20
|
-
|
|
21
|
-
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
22
|
-
const request = context.switchToHttp().getRequest<Request>()
|
|
23
|
-
const session = await this.auth.api.getSession({ headers: fromNodeHeaders(request.headers) })
|
|
24
|
-
|
|
25
|
-
request.user = session?.user
|
|
26
|
-
request.session = session?.session
|
|
27
|
-
|
|
28
|
-
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, [
|
|
29
|
-
context.getHandler(),
|
|
30
|
-
context.getClass(),
|
|
31
|
-
])
|
|
32
|
-
|
|
33
|
-
if (isPublic === true) {
|
|
34
|
-
return true
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (session === null) {
|
|
38
|
-
throw new UnauthorizedException()
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return true
|
|
42
|
-
}
|
|
43
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { Global, Module } from '@nestjs/common'
|
|
2
|
-
import { APP_GUARD } from '@nestjs/core'
|
|
3
|
-
import { Env } from '@/config/env'
|
|
4
|
-
import { UsersModule } from '@/users/users.module'
|
|
5
|
-
import { UsersService } from '@/users/users.service'
|
|
6
|
-
import { AUTH } from './auth.constants'
|
|
7
|
-
import { AuthController } from './auth.controller'
|
|
8
|
-
import { createAuth } from './auth.factory'
|
|
9
|
-
import { AuthGuard } from './auth.guard'
|
|
10
|
-
|
|
11
|
-
@Global()
|
|
12
|
-
@Module({
|
|
13
|
-
imports: [UsersModule],
|
|
14
|
-
controllers: [AuthController],
|
|
15
|
-
providers: [
|
|
16
|
-
{
|
|
17
|
-
provide: AUTH,
|
|
18
|
-
inject: [Env, UsersService],
|
|
19
|
-
useFactory: (env: Env, users: UsersService) =>
|
|
20
|
-
createAuth(env, {
|
|
21
|
-
onUserCreated: async (user) => {
|
|
22
|
-
await users.createProfile(user.id, user.name)
|
|
23
|
-
},
|
|
24
|
-
}),
|
|
25
|
-
},
|
|
26
|
-
{ provide: APP_GUARD, useClass: AuthGuard },
|
|
27
|
-
],
|
|
28
|
-
exports: [AUTH],
|
|
29
|
-
})
|
|
30
|
-
export class AuthModule {}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { createParamDecorator, type ExecutionContext } from '@nestjs/common'
|
|
2
|
-
import type { Request } from 'express'
|
|
3
|
-
import type { AuthUser } from './auth.factory'
|
|
4
|
-
|
|
5
|
-
export const CurrentUser = createParamDecorator(
|
|
6
|
-
(_data: unknown, context: ExecutionContext): AuthUser | undefined => {
|
|
7
|
-
return context.switchToHttp().getRequest<Request>().user
|
|
8
|
-
},
|
|
9
|
-
)
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { Global, Module } from '@nestjs/common'
|
|
2
|
-
import { ConfigModule } from '@nestjs/config'
|
|
3
|
-
import { Env, loadEnv } from './env'
|
|
4
|
-
|
|
5
|
-
@Global()
|
|
6
|
-
@Module({
|
|
7
|
-
imports: [ConfigModule.forRoot({ cache: true, ignoreEnvFile: true, validate: loadEnv })],
|
|
8
|
-
providers: [{ provide: Env, useFactory: loadEnv }],
|
|
9
|
-
exports: [Env],
|
|
10
|
-
})
|
|
11
|
-
export class EnvModule {}
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import 'reflect-metadata'
|
|
2
|
-
import { existsSync } from 'node:fs'
|
|
3
|
-
import { plainToInstance } from 'class-transformer'
|
|
4
|
-
import {
|
|
5
|
-
IsEnum,
|
|
6
|
-
IsInt,
|
|
7
|
-
IsOptional,
|
|
8
|
-
IsString,
|
|
9
|
-
Matches,
|
|
10
|
-
Max,
|
|
11
|
-
Min,
|
|
12
|
-
MinLength,
|
|
13
|
-
validateSync,
|
|
14
|
-
} from 'class-validator'
|
|
15
|
-
|
|
16
|
-
export type NodeEnv = 'development' | 'test' | 'production'
|
|
17
|
-
export type LogLevel = 'error' | 'warn' | 'info' | 'debug'
|
|
18
|
-
|
|
19
|
-
export class Env {
|
|
20
|
-
@IsEnum(['development', 'test', 'production'])
|
|
21
|
-
NODE_ENV: NodeEnv
|
|
22
|
-
|
|
23
|
-
@IsInt()
|
|
24
|
-
@Min(1)
|
|
25
|
-
@Max(65535)
|
|
26
|
-
PORT: number
|
|
27
|
-
|
|
28
|
-
@IsString()
|
|
29
|
-
@MinLength(1)
|
|
30
|
-
DATABASE_URL: string
|
|
31
|
-
|
|
32
|
-
@IsString()
|
|
33
|
-
@MinLength(1)
|
|
34
|
-
AUTH_DATABASE_URL: string
|
|
35
|
-
|
|
36
|
-
@IsString()
|
|
37
|
-
@MinLength(32)
|
|
38
|
-
BETTER_AUTH_SECRET: string
|
|
39
|
-
|
|
40
|
-
@IsString()
|
|
41
|
-
@MinLength(1)
|
|
42
|
-
BETTER_AUTH_URL: string
|
|
43
|
-
|
|
44
|
-
@IsString()
|
|
45
|
-
@MinLength(1)
|
|
46
|
-
WEB_ORIGIN: string
|
|
47
|
-
|
|
48
|
-
@IsOptional()
|
|
49
|
-
@Matches(/^[a-z][a-z0-9+.-]*$/)
|
|
50
|
-
MOBILE_APP_SCHEME?: string
|
|
51
|
-
|
|
52
|
-
@IsEnum(['error', 'warn', 'info', 'debug'])
|
|
53
|
-
LOG_LEVEL: LogLevel
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function validate(raw: Record<string, unknown>): Env {
|
|
57
|
-
const env = plainToInstance(Env, raw, { enableImplicitConversion: true })
|
|
58
|
-
const errors = validateSync(env, { whitelist: true, forbidUnknownValues: false })
|
|
59
|
-
|
|
60
|
-
if (errors.length > 0) {
|
|
61
|
-
const named = errors.map((error) => error.property).join(', ')
|
|
62
|
-
throw new Error(`Invalid environment: ${named}`)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
loaded = env
|
|
66
|
-
return env
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
let loaded: Env | undefined
|
|
70
|
-
|
|
71
|
-
export function loadEnv(): Env {
|
|
72
|
-
if (loaded !== undefined) {
|
|
73
|
-
return loaded
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (process.env.NODE_ENV !== 'production' && existsSync('.env')) {
|
|
77
|
-
process.loadEnvFile('.env')
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return validate(process.env)
|
|
81
|
-
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type ArgumentsHost,
|
|
3
|
-
Catch,
|
|
4
|
-
type ExceptionFilter,
|
|
5
|
-
HttpException,
|
|
6
|
-
HttpStatus,
|
|
7
|
-
Logger,
|
|
8
|
-
} from '@nestjs/common'
|
|
9
|
-
import type { Request, Response } from 'express'
|
|
10
|
-
import { RequestContextService } from '@/request-context/request-context.service'
|
|
11
|
-
import { ValidationFailedException } from './validation-exception.factory'
|
|
12
|
-
|
|
13
|
-
type ProblemDocument = {
|
|
14
|
-
type: string
|
|
15
|
-
title: string
|
|
16
|
-
status: number
|
|
17
|
-
detail: string
|
|
18
|
-
instance: string
|
|
19
|
-
requestId?: string
|
|
20
|
-
errors?: Record<string, string[]>
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const TITLES: Record<number, string> = {
|
|
24
|
-
400: 'Bad Request',
|
|
25
|
-
401: 'Unauthorized',
|
|
26
|
-
403: 'Forbidden',
|
|
27
|
-
404: 'Not Found',
|
|
28
|
-
409: 'Conflict',
|
|
29
|
-
422: 'Unprocessable Entity',
|
|
30
|
-
500: 'Internal Server Error',
|
|
31
|
-
503: 'Service Unavailable',
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function detailOf(exception: HttpException): string {
|
|
35
|
-
const response = exception.getResponse()
|
|
36
|
-
|
|
37
|
-
if (typeof response === 'string') {
|
|
38
|
-
return response
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const message = (response as { message?: unknown }).message
|
|
42
|
-
|
|
43
|
-
if (typeof message === 'string') {
|
|
44
|
-
return message
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return exception.message
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
@Catch()
|
|
51
|
-
export class ProblemDetailsFilter implements ExceptionFilter {
|
|
52
|
-
private readonly logger = new Logger(ProblemDetailsFilter.name)
|
|
53
|
-
|
|
54
|
-
constructor(private readonly context: RequestContextService) {}
|
|
55
|
-
|
|
56
|
-
catch(exception: unknown, host: ArgumentsHost): void {
|
|
57
|
-
const http = host.switchToHttp()
|
|
58
|
-
const request = http.getRequest<Request>()
|
|
59
|
-
const response = http.getResponse<Response>()
|
|
60
|
-
|
|
61
|
-
const status =
|
|
62
|
-
exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR
|
|
63
|
-
|
|
64
|
-
const problem: ProblemDocument = {
|
|
65
|
-
type: 'about:blank',
|
|
66
|
-
title: TITLES[status] ?? 'Error',
|
|
67
|
-
status,
|
|
68
|
-
detail:
|
|
69
|
-
exception instanceof HttpException && status < HttpStatus.INTERNAL_SERVER_ERROR
|
|
70
|
-
? detailOf(exception)
|
|
71
|
-
: 'An unexpected error occurred.',
|
|
72
|
-
instance: request.originalUrl,
|
|
73
|
-
requestId: this.context.requestId,
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (exception instanceof ValidationFailedException) {
|
|
77
|
-
problem.errors = exception.fields
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (status >= HttpStatus.INTERNAL_SERVER_ERROR) {
|
|
81
|
-
this.logger.error(exception)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
response.status(status).type('application/problem+json').json(problem)
|
|
85
|
-
}
|
|
86
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { BadRequestException, type ValidationError } from '@nestjs/common'
|
|
2
|
-
|
|
3
|
-
export type FieldErrors = Record<string, string[]>
|
|
4
|
-
|
|
5
|
-
function collect(errors: ValidationError[], prefix: string, into: FieldErrors): void {
|
|
6
|
-
for (const error of errors) {
|
|
7
|
-
const key = prefix === '' ? error.property : `${prefix}.${error.property}`
|
|
8
|
-
const messages = Object.values(error.constraints ?? {})
|
|
9
|
-
|
|
10
|
-
if (messages.length > 0) {
|
|
11
|
-
into[key] = messages
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
if (error.children !== undefined && error.children.length > 0) {
|
|
15
|
-
collect(error.children, key, into)
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export class ValidationFailedException extends BadRequestException {
|
|
21
|
-
constructor(readonly fields: FieldErrors) {
|
|
22
|
-
super('Validation failed')
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function validationExceptionFactory(errors: ValidationError[]): ValidationFailedException {
|
|
27
|
-
const fields: FieldErrors = {}
|
|
28
|
-
collect(errors, '', fields)
|
|
29
|
-
return new ValidationFailedException(fields)
|
|
30
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common'
|
|
2
|
-
import { ApiTags } from '@nestjs/swagger'
|
|
3
|
-
import {
|
|
4
|
-
HealthCheck,
|
|
5
|
-
type HealthCheckResult,
|
|
6
|
-
HealthCheckService,
|
|
7
|
-
MikroOrmHealthIndicator,
|
|
8
|
-
} from '@nestjs/terminus'
|
|
9
|
-
import { Public } from '@/auth/public.decorator'
|
|
10
|
-
|
|
11
|
-
@ApiTags('health')
|
|
12
|
-
@Controller({ path: 'health', version: VERSION_NEUTRAL })
|
|
13
|
-
export class HealthController {
|
|
14
|
-
constructor(
|
|
15
|
-
private readonly health: HealthCheckService,
|
|
16
|
-
private readonly database: MikroOrmHealthIndicator,
|
|
17
|
-
) {}
|
|
18
|
-
|
|
19
|
-
@Public()
|
|
20
|
-
@Get('live')
|
|
21
|
-
@HealthCheck()
|
|
22
|
-
live(): Promise<HealthCheckResult> {
|
|
23
|
-
return this.health.check([])
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
@Public()
|
|
27
|
-
@Get('ready')
|
|
28
|
-
@HealthCheck()
|
|
29
|
-
ready(): Promise<HealthCheckResult> {
|
|
30
|
-
return this.health.check([() => this.database.pingCheck('database')])
|
|
31
|
-
}
|
|
32
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { MikroORM } from '@mikro-orm/postgresql'
|
|
2
|
-
import { ClassSerializerInterceptor, ValidationPipe, VersioningType } from '@nestjs/common'
|
|
3
|
-
import { NestFactory, Reflector } from '@nestjs/core'
|
|
4
|
-
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
|
|
5
|
-
import { Logger } from 'nestjs-pino'
|
|
6
|
-
import { AppModule } from './app.module'
|
|
7
|
-
import { Env } from './config/env'
|
|
8
|
-
import { validationExceptionFactory } from './errors/validation-exception.factory'
|
|
9
|
-
|
|
10
|
-
async function bootstrap(): Promise<void> {
|
|
11
|
-
const app = await NestFactory.create(AppModule, { bufferLogs: true })
|
|
12
|
-
const env = app.get(Env)
|
|
13
|
-
|
|
14
|
-
app.useLogger(app.get(Logger))
|
|
15
|
-
app.setGlobalPrefix('api')
|
|
16
|
-
app.enableCors({ origin: env.WEB_ORIGIN, credentials: true, exposedHeaders: ['X-Request-Id'] })
|
|
17
|
-
app.enableVersioning({ type: VersioningType.URI })
|
|
18
|
-
|
|
19
|
-
app.useGlobalPipes(
|
|
20
|
-
new ValidationPipe({
|
|
21
|
-
whitelist: true,
|
|
22
|
-
forbidNonWhitelisted: true,
|
|
23
|
-
transform: true,
|
|
24
|
-
exceptionFactory: validationExceptionFactory,
|
|
25
|
-
}),
|
|
26
|
-
)
|
|
27
|
-
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))
|
|
28
|
-
|
|
29
|
-
if (env.NODE_ENV !== 'production') {
|
|
30
|
-
const document = SwaggerModule.createDocument(
|
|
31
|
-
app,
|
|
32
|
-
new DocumentBuilder().setTitle('API').setVersion('1').build(),
|
|
33
|
-
)
|
|
34
|
-
SwaggerModule.setup('api/docs', app, document)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// MikroORM 7 no longer connects on init, so readiness would report the database down until a first query.
|
|
38
|
-
await app.get(MikroORM).connect()
|
|
39
|
-
|
|
40
|
-
await app.listen(env.PORT)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
void bootstrap()
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { Migrator } from '@mikro-orm/migrations'
|
|
2
|
-
import { defineConfig } from '@mikro-orm/postgresql'
|
|
3
|
-
import type { Env } from './config/env'
|
|
4
|
-
import { Profile } from './users/entities/profile.entity'
|
|
5
|
-
|
|
6
|
-
export function createOrmConfig(env: Pick<Env, 'DATABASE_URL' | 'NODE_ENV'>) {
|
|
7
|
-
return defineConfig({
|
|
8
|
-
clientUrl: env.DATABASE_URL,
|
|
9
|
-
entities: [Profile],
|
|
10
|
-
extensions: [Migrator],
|
|
11
|
-
migrations: { path: './migrations', snapshot: false },
|
|
12
|
-
debug: env.NODE_ENV === 'development',
|
|
13
|
-
})
|
|
14
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
-
import { Injectable } from '@nestjs/common'
|
|
3
|
-
|
|
4
|
-
type Store = { requestId: string }
|
|
5
|
-
|
|
6
|
-
@Injectable()
|
|
7
|
-
export class RequestContextService {
|
|
8
|
-
private readonly storage = new AsyncLocalStorage<Store>()
|
|
9
|
-
|
|
10
|
-
run<T>(requestId: string, callback: () => T): T {
|
|
11
|
-
return this.storage.run({ requestId }, callback)
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
get requestId(): string | undefined {
|
|
15
|
-
return this.storage.getStore()?.requestId
|
|
16
|
-
}
|
|
17
|
-
}
|