@stjoseph/prumo 0.0.6 → 0.1.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 (140) hide show
  1. package/.prumo-templates/api/auth.md +51 -95
  2. package/.prumo-templates/api/config.md +30 -56
  3. package/.prumo-templates/api/errors.md +64 -44
  4. package/.prumo-templates/api/logging.md +27 -44
  5. package/.prumo-templates/api/observability.md +36 -62
  6. package/.prumo-templates/api/pagination.md +23 -38
  7. package/.prumo-templates/api/routes.md +67 -48
  8. package/.prumo-templates/api/structure.md +130 -0
  9. package/.prumo-templates/client/data.md +4 -3
  10. package/.prumo-templates/core/conventions.md +10 -7
  11. package/.prumo-templates/core/testing.md +43 -26
  12. package/.prumo-templates/core/tooling.md +33 -30
  13. package/.prumo-templates/database/entities.md +53 -61
  14. package/.prumo-templates/database/queries.md +34 -47
  15. package/.prumo-templates/database/transactions.md +28 -38
  16. package/.prumo-templates/mcp/server.md +54 -0
  17. package/.prumo-templates/monorepo/dependencies.md +3 -3
  18. package/.prumo-templates/monorepo/layout.md +1 -1
  19. package/README.md +6 -6
  20. package/dist/asker.js +4 -0
  21. package/dist/cli.js +7 -8
  22. package/dist/commands.js +2 -0
  23. package/dist/compose.js +6 -2
  24. package/dist/context.js +5 -0
  25. package/dist/generate.js +8 -0
  26. package/dist/mcp.js +100 -0
  27. package/dist/questions.js +21 -1
  28. package/dist/version.js +7 -0
  29. package/package.json +4 -3
  30. package/templates/api/.swcrc +13 -0
  31. package/templates/api/biome.jsonc +5 -3
  32. package/templates/api/package.json +21 -27
  33. package/templates/api/pnpm-workspace.yaml +0 -2
  34. package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
  35. package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
  36. package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
  37. package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
  38. package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
  39. package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
  40. package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
  41. package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
  42. package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
  43. package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
  44. package/templates/api/src/infra/auth/auth.cli.ts +5 -0
  45. package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
  46. package/templates/api/src/infra/config/env.ts +47 -0
  47. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
  48. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
  49. package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
  50. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
  51. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
  52. package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
  53. package/templates/api/src/infra/di/index.ts +32 -0
  54. package/templates/api/src/infra/di/users.di.ts +7 -0
  55. package/templates/api/src/infra/http/app.ts +99 -0
  56. package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
  57. package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
  58. package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
  59. package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
  60. package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
  61. package/templates/api/src/infra/http/errors/http-error.ts +8 -0
  62. package/templates/api/src/infra/http/fastify.d.ts +17 -0
  63. package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
  64. package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
  65. package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
  66. package/templates/api/src/infra/http/server.ts +34 -0
  67. package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
  68. package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
  69. package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
  70. package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
  71. package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
  72. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
  73. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
  74. package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
  75. package/templates/api/test/infra/http/app.spec.ts +21 -0
  76. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
  77. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
  78. package/templates/api/test/support/factories/profile.factory.ts +25 -0
  79. package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
  80. package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
  81. package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
  82. package/templates/api/tsconfig.json +3 -6
  83. package/templates/api/vitest.config.mts +4 -4
  84. package/templates/mobile/jest.config.js +1 -1
  85. package/templates/mobile/package.json +2 -2
  86. package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
  87. package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  88. package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
  89. package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  90. package/templates/mobile/tsconfig.json +2 -1
  91. package/templates/site/{src → test}/app/page.spec.ts +1 -1
  92. package/templates/site/tsconfig.json +2 -1
  93. package/templates/site/vitest.config.mts +4 -2
  94. package/templates/web/package.json +4 -2
  95. package/templates/web/src/features/auth/auth-client.ts +5 -0
  96. package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
  97. package/templates/web/src/features/oauth/consent-form.tsx +63 -0
  98. package/templates/web/src/routeTree.gen.ts +21 -2
  99. package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
  100. package/templates/web/{src → test}/app.spec.tsx +2 -2
  101. package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  102. package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
  103. package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  104. package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
  105. package/templates/web/tsconfig.json +2 -1
  106. package/templates/web/vite.config.ts +3 -3
  107. package/.prumo-templates/api/modules.md +0 -117
  108. package/templates/api/nest-cli.json +0 -10
  109. package/templates/api/src/app.module.ts +0 -46
  110. package/templates/api/src/auth/auth.constants.ts +0 -2
  111. package/templates/api/src/auth/auth.controller.ts +0 -19
  112. package/templates/api/src/auth/auth.guard.ts +0 -43
  113. package/templates/api/src/auth/auth.module.ts +0 -30
  114. package/templates/api/src/auth/auth.ts +0 -4
  115. package/templates/api/src/auth/current-user.decorator.ts +0 -9
  116. package/templates/api/src/auth/express.d.ts +0 -10
  117. package/templates/api/src/auth/public.decorator.ts +0 -4
  118. package/templates/api/src/config/env.module.ts +0 -11
  119. package/templates/api/src/config/env.ts +0 -81
  120. package/templates/api/src/errors/problem-details.filter.ts +0 -86
  121. package/templates/api/src/errors/validation-exception.factory.ts +0 -30
  122. package/templates/api/src/health/health.controller.ts +0 -32
  123. package/templates/api/src/health/health.module.ts +0 -9
  124. package/templates/api/src/main.ts +0 -43
  125. package/templates/api/src/mikro-orm.factory.ts +0 -14
  126. package/templates/api/src/request-context/request-context.module.ts +0 -9
  127. package/templates/api/src/request-context/request-context.service.ts +0 -17
  128. package/templates/api/src/request-context/request-id.middleware.ts +0 -19
  129. package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
  130. package/templates/api/src/users/entities/profile.entity.ts +0 -27
  131. package/templates/api/src/users/users.controller.ts +0 -23
  132. package/templates/api/src/users/users.module.ts +0 -10
  133. package/templates/api/src/users/users.service.spec.ts +0 -44
  134. package/templates/api/src/users/users.service.ts +0 -36
  135. package/templates/api/test/factories/profile.factory.ts +0 -26
  136. package/templates/api/tsconfig.build.json +0 -8
  137. /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
  138. /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  139. /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  140. /package/templates/web/test/{setup.ts → support/setup.ts} +0 -0
@@ -0,0 +1,45 @@
1
+ import { fakeTransport, json } from '@test/support/fake-transport'
2
+ import { renderApp } from '@test/support/render-app'
3
+ import { screen } from '@testing-library/react'
4
+ import userEvent from '@testing-library/user-event'
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+
7
+ const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
8
+
9
+ // The shape Better Auth signs: every signed name listed in ba_param, then the signature.
10
+ const SIGNED =
11
+ 'client_id=client-1&scope=openid+profile&ba_param=ba_param&ba_param=client_id&ba_param=scope&sig=signature'
12
+
13
+ afterEach(() => {
14
+ window.history.replaceState(null, '', '/')
15
+ })
16
+
17
+ describe('ConsentForm', () => {
18
+ it('names the client, lists the scopes and sends the signed query with the answer', async () => {
19
+ const visitor = userEvent.setup()
20
+ let consent: Record<string, unknown> | undefined
21
+
22
+ // The OAuth client plugin reads the browser's own URL, not the router's memory history.
23
+ window.history.replaceState(null, '', `/consent?${SIGNED}&unrelated=1`)
24
+
25
+ renderApp(
26
+ `/consent?${SIGNED}`,
27
+ fakeTransport({
28
+ 'GET /api/auth/get-session': () =>
29
+ json(200, { session: { id: 's-1', userId: user.id }, user }),
30
+ 'GET /api/auth/oauth2/public-client': () => json(200, { client_name: 'Claude' }),
31
+ 'POST /api/auth/oauth2/consent': async (request) => {
32
+ consent = await request.json()
33
+ return json(200, { redirect: false })
34
+ },
35
+ }),
36
+ )
37
+
38
+ expect(await screen.findByText('Claude')).toBeInTheDocument()
39
+ expect(screen.getByText('profile')).toBeInTheDocument()
40
+
41
+ await visitor.click(screen.getByRole('button', { name: 'Allow' }))
42
+
43
+ expect(consent).toMatchObject({ accept: true, oauth_query: SIGNED })
44
+ })
45
+ })
@@ -1,10 +1,10 @@
1
1
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
2
3
  import { render, screen } from '@testing-library/react'
3
4
  import userEvent from '@testing-library/user-event'
4
5
  import { describe, expect, it } from 'vitest'
5
6
  import { createClient, type Profile } from '@/api-contract'
6
- import { API_URL, fakeTransport, json } from '../../../test/fake-transport'
7
- import { ProfileForm } from './profile-form'
7
+ import { ProfileForm } from '@/features/profile/profile-form'
8
8
 
9
9
  const profile: Profile = {
10
10
  id: '01a09ffe-635c-77e5-9aeb-d3e9d1a7ee24',
@@ -1,10 +1,10 @@
1
1
  import { QueryClient } from '@tanstack/react-query'
2
2
  import { createMemoryHistory } from '@tanstack/react-router'
3
+ import { API_URL } from '@test/support/fake-transport'
3
4
  import { render } from '@testing-library/react'
4
5
  import { createClient, type Transport } from '@/api-contract'
5
6
  import { App } from '@/app'
6
7
  import { createAuth } from '@/features/auth/auth-client'
7
- import { API_URL } from './fake-transport'
8
8
 
9
9
  export function renderApp(path: string, transport: Transport) {
10
10
  const context = {
@@ -13,7 +13,8 @@
13
13
  "isolatedModules": true,
14
14
  "noEmit": true,
15
15
  "paths": {
16
- "@/*": ["./src/*"]
16
+ "@/*": ["./src/*"],
17
+ "@test/*": ["./test/*"]
17
18
  },
18
19
  "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom/vitest"]
19
20
  },
@@ -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: ['src/**/*.spec.{ts,tsx}'],
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,10 +0,0 @@
1
- {
2
- "$schema": "https://json.schemastore.org/nest-cli",
3
- "collection": "@nestjs/schematics",
4
- "sourceRoot": "src",
5
- "compilerOptions": {
6
- "deleteOutDir": true,
7
- "tsConfigPath": "tsconfig.build.json",
8
- "plugins": ["@nestjs/swagger/plugin"]
9
- }
10
- }
@@ -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,2 +0,0 @@
1
- export const AUTH = Symbol('AUTH')
2
- export const IS_PUBLIC = Symbol('IS_PUBLIC')
@@ -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,4 +0,0 @@
1
- import { loadEnv } from '@/config/env'
2
- import { createAuth } from './auth.factory'
3
-
4
- export const auth = createAuth(loadEnv())
@@ -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,10 +0,0 @@
1
- import type { AuthSession, AuthUser } from './auth.factory'
2
-
3
- declare global {
4
- namespace Express {
5
- interface Request {
6
- user?: AuthUser
7
- session?: NonNullable<AuthSession>['session']
8
- }
9
- }
10
- }
@@ -1,4 +0,0 @@
1
- import { SetMetadata } from '@nestjs/common'
2
- import { IS_PUBLIC } from './auth.constants'
3
-
4
- export const Public = () => SetMetadata(IS_PUBLIC, true)
@@ -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,9 +0,0 @@
1
- import { Module } from '@nestjs/common'
2
- import { TerminusModule } from '@nestjs/terminus'
3
- import { HealthController } from './health.controller'
4
-
5
- @Module({
6
- imports: [TerminusModule],
7
- controllers: [HealthController],
8
- })
9
- export class HealthModule {}